Sfoglia il codice sorgente

Merge pull request #792 from ShiGuangSchedule/pending

星河欲转 1 mese fa
parent
commit
82185c0835
4 ha cambiato i file con 592 aggiunte e 46 eliminazioni
  1. 6 1
      index/root_index.yaml
  2. 9 0
      resources/FVTI/adapters.yaml
  3. 335 0
      resources/FVTI/fvti_01.js
  4. 242 45
      resources/JNMC/jnmc.js

+ 6 - 1
index/root_index.yaml

@@ -218,6 +218,11 @@ schools:
     initial: "F"
     resource_folder: "FJPIT"
 
+  - id: "FVTI"
+    name: "福州职业技术大学"
+    initial: "F"
+    resource_folder: "FVTI"
+
   - id: "FOSU"
     name: "佛山大学"
     initial: "F"
@@ -1182,4 +1187,4 @@ schools:
     name: "泉州职业技术大学"
     initial: "Q"
     resource_folder: "QVTU"
-    
+    

+ 9 - 0
resources/FVTI/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/FVTI/adapters.yaml
+adapters:
+  - adapter_id: "FVTI_01"
+    adapter_name: "福州职业技术大学"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "fvti_01.js"
+    import_url: "https://jw.webvpn.fvti.cn/Framework/lyssoNew/login.php"
+    maintainer: "Panda君"
+    description: "登录教务系统后直接点导入即可;自动抓取周课表并导入课程、作息时间与开学日期,实测正常使用,如有问题请提issues。"

+ 335 - 0
resources/FVTI/fvti_01.js

@@ -0,0 +1,335 @@
+// 福州职业技术大学(FVTI)课表导入
+// 课表走移动端接口 POST /studentportal.php/Appusermobile/zkcb(optype=xszkcb&dqz=周次)
+// 学期总周数与节次时间接口都不提供,从桌面周课表页读取(周次标签 + 行标题里的节次时间)
+// 读取失败时:总周数退回"最后一个有课的周",节次时间则不导入(课程导入不受影响)
+// 合规:不使用自建 DOM 控件,交互全部走原生桥接;DOMParser 只解析接口返回的独立片段
+(function () {
+    'use strict';
+
+    var Bridge = window.AndroidBridge || window.shiguangBridge || null;
+    var BridgePromise = window.AndroidBridgePromise || window.shiguangBridgePromise || null;
+
+    var P = '/studentportal.php';
+    var API = P + '/Appusermobile/zkcb';
+    var WEEK_TABS = P + '/Jxxx/xskbxx/optype/1';
+    var CHUNK = 6;             // 每批并发周数(服务端按会话串行,分批只为降低突发)
+    var SCAN_MAX_WEEK = 24;    // 拿不到周数时的扫描上限
+    var TIMEOUT_MS = 12000;
+    var MAX_RETRY = 2;
+
+    // ---------- 基础工具 ----------
+
+    function toast(msg) {
+        try { Bridge && Bridge.showToast ? Bridge.showToast(msg) : console.log('[toast] ' + msg); } catch (e) {}
+    }
+
+    function delay(ms) {
+        return new Promise(function (r) { setTimeout(r, ms); });
+    }
+
+    function norm(s) {
+        return String(s == null ? '' : s).replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
+    }
+
+    function pad2(n) {
+        return n < 10 ? '0' + n : '' + n;
+    }
+
+    // 兼容校内直连与 WebVPN 代理:取 /studentportal.php 之前的部分作为前缀
+    function url(path) {
+        var i = location.href.indexOf(P);
+        return (i >= 0 ? location.href.slice(0, i) : location.origin) + path;
+    }
+
+    // ---------- 请求 ----------
+
+    // 通用请求:超时 + 自动重试
+    async function request(path, options) {
+        var lastError = null;
+        for (var attempt = 1; attempt <= MAX_RETRY; attempt++) {
+            var controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
+            var timer = controller ? setTimeout(function () { controller.abort(); }, TIMEOUT_MS) : null;
+            try {
+                var res = await fetch(url(path), Object.assign({ credentials: 'include', signal: controller ? controller.signal : undefined }, options || {}));
+                if (!res.ok) throw new Error('HTTP ' + res.status);
+                return await res.text();
+            } catch (e) {
+                lastError = e;
+            } finally {
+                if (timer) clearTimeout(timer);
+            }
+            if (attempt < MAX_RETRY) await delay(300 * attempt);
+        }
+        throw lastError;
+    }
+
+    // 取一节课表:返回该周日期 + 表格
+    async function fetchWeek(week) {
+        var text = await request(API, {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
+            body: 'optype=xszkcb&dqz=' + week,
+        });
+        var json = JSON.parse(text);
+        if (String(json.Code) !== '1' || !json.Data) throw new Error('接口返回异常');
+        return { week: week, title: json.Data.title || '', dates: (json.Data.kcbrq || []).map(function (x) { return x.rq; }), table: parseTable(json.Data.kcb) };
+    }
+
+    // 桌面周课表壳页:既给学期总周数(每周地址含 /dqz/N/),也给第 1 周的地址
+    async function fetchWeekTabs() {
+        try {
+            var html = await request(WEEK_TABS, {});
+            var max = 0;
+            var re = /\/dqz\/(\d+)\//g;
+            var m;
+            while ((m = re.exec(html)) !== null) if (Number(m[1]) > max) max = Number(m[1]);
+            var first = /attr\('src','([^']+)'\)/.exec(html);
+            return { totalWeeks: max, firstWeekUrl: first ? first[1] : '' };
+        } catch (e) {
+            return { totalWeeks: 0, firstWeekUrl: '' };
+        }
+    }
+
+    // 节次时间:接口只给节次序号,时间从周课表页行标题("第1节<br>08:30-09:15")读取
+    async function fetchSectionTimes(firstWeekUrl) {
+        if (!firstWeekUrl) return null;
+        try {
+            var html = await request(firstWeekUrl, {});
+            var doc = new DOMParser().parseFromString(html, 'text/html');
+            var rows = Array.prototype.slice.call(doc.querySelectorAll('tr'));
+            var slots = [];
+            for (var i = 1; i < rows.length; i++) {
+                var td = rows[i].querySelector('td');
+                if (!td) continue;
+                var sec = /第(\d+)节/.exec(norm(td.textContent));
+                var time = /(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/.exec(td.innerHTML.replace(/<br\s*\/?>/gi, ' '));
+                if (sec && time) slots.push({ number: Number(sec[1]), startTime: time[1], endTime: time[2] });
+            }
+            return slots.length ? slots : null;
+        } catch (e) {
+            return null;
+        }
+    }
+
+    // ---------- 解析 ----------
+
+    // 表格按 rowspan 展开;中午1/中午2 这类非上课行记为 null 行(会被跳过)
+    function parseTable(html) {
+        var doc = new DOMParser().parseFromString(html || '', 'text/html');
+        var rows = Array.prototype.slice.call(doc.querySelectorAll('tr'));
+        var sections = [];
+        var grid = [];
+        for (var r = 0; r < rows.length; r++) {
+            var cells = Array.prototype.slice.call(rows[r].children);
+            if (!cells.length) continue;
+            var label = norm(cells[0].textContent);
+            sections[r] = /^\d+$/.test(label) ? Number(label) : null;
+            grid[r] = grid[r] || [];
+            var day = 1;
+            for (var k = 1; k < cells.length; k++) {
+                while (grid[r][day] !== undefined) day++;
+                var rowspan = Number(cells[k].getAttribute('rowspan') || 1);
+                var cell = { text: norm(cells[k].textContent) };
+                for (var m = 0; m < rowspan; m++) {
+                    grid[r + m] = grid[r + m] || [];
+                    grid[r + m][day] = cell;
+                }
+                day++;
+            }
+        }
+        return { sections: sections, grid: grid };
+    }
+
+    // 格子文本 = 「课程名 地点 教师」:课程名在第一段,末段没括号就是教师,中间是地点
+    function splitCell(text) {
+        var parts = norm(text).split(' ').filter(Boolean);
+        if (!parts.length) return null;
+        var name = parts[0];
+        var rest = parts.slice(1);
+        var teacher = '';
+        var position = '';
+        if (rest.length) {
+            var last = rest[rest.length - 1];
+            if (last.indexOf('(') < 0 && last.indexOf('(') < 0) {
+                teacher = last;
+                position = rest.slice(0, -1).join(' ');
+            } else {
+                position = rest.join(' ');
+            }
+        }
+        return { name: name, teacher: teacher, position: position };
+    }
+
+    // 连续相同的格子算一门连堂课,合并进课程表
+    function addWeek(week, table, sink) {
+        for (var day = 1; day <= 7; day++) {
+            var block = null;
+            var push = function () {
+                if (!block) return;
+                var key = [block.name, block.teacher, block.position, day, block.startSection, block.endSection].join('|');
+                if (!sink[key]) sink[key] = { name: block.name, teacher: block.teacher || '未知教师', position: block.position, day: day, startSection: block.startSection, endSection: block.endSection, weeks: [] };
+                if (sink[key].weeks.indexOf(week) < 0) sink[key].weeks.push(week);
+            };
+            for (var r = 0; r < table.sections.length; r++) {
+                var section = table.sections[r];
+                if (section === null) continue;
+                var cell = (table.grid[r] || [])[day];
+                var info = cell && cell.text ? splitCell(cell.text) : null;
+                if (!info || !info.name) continue;
+                if (block && block.name === info.name && block.teacher === info.teacher && block.position === info.position && r === block.endRow + 1) {
+                    block.endRow = r;
+                    block.endSection = section;
+                } else {
+                    push();
+                    block = { name: info.name, teacher: info.teacher, position: info.position, startSection: section, endSection: section, endRow: r };
+                }
+            }
+            push();
+        }
+    }
+
+    // ---------- 取数主流程 ----------
+
+    // 先并发首批 + 同时问周数,再用权威周数补齐剩余批次
+    // 分批发请求:同一会话在服务端会排队,分批只是避免一次性打太多
+    async function fetchChunks(list) {
+        var out = [];
+        for (var i = 0; i < list.length; i += CHUNK) {
+            var batch = list.slice(i, i + CHUNK);
+            var res = await Promise.all(batch.map(function (w) {
+                return fetchWeek(w).catch(function () { return { week: w, error: true }; });
+            }));
+            out = out.concat(res);
+        }
+        return out;
+    }
+
+    async function fetchAll() {
+        var started = Date.now();
+        var sink = {};
+        var weeks = [];
+        var title = '';
+        var firstDates = null;
+        var failed = [];
+
+        var weekTabsPromise = fetchWeekTabs();
+        var first = [];
+        for (var i = 1; i <= CHUNK; i++) first.push(i);
+        var results = await fetchChunks(first);
+
+        var weekTabs = await weekTabsPromise;
+        var totalWeeks = weekTabs.totalWeeks;
+        var scanCount = totalWeeks > 0 ? totalWeeks : SCAN_MAX_WEEK;
+        var rest = [];
+        for (var j = CHUNK + 1; j <= scanCount; j++) rest.push(j);
+        if (rest.length) results = results.concat(await fetchChunks(rest));
+
+        results.forEach(function (r) {
+            if (r.error) {
+                failed.push(r.week);
+                return;
+            }
+            weeks.push(r.week);
+            addWeek(r.week, r.table, sink);
+            if (!title && r.title) title = r.title;
+            if (r.week === 1 && r.dates.length) firstDates = r.dates;
+            if (!firstDates && r.dates.length && (!weeks.length || r.week === Math.min.apply(null, weeks))) firstDates = r.dates;
+        });
+        if (failed.length) throw new Error('第 ' + failed.sort(function (a, b) { return a - b; }).join('、') + ' 周数据没取到,请重试');
+        if (!weeks.length) throw new Error('接口没有返回任何周次的课表');
+
+        var courses = Object.keys(sink).map(function (k) {
+            sink[k].weeks.sort(function (a, b) { return a - b; });
+            return sink[k];
+        }).sort(function (a, b) {
+            return a.day - b.day || a.startSection - b.startSection || a.name.localeCompare(b.name);
+        });
+        if (!courses.length) throw new Error('本学期没有解析到任何课程');
+
+        var maxCourseWeek = 0;
+        courses.forEach(function (c) { maxCourseWeek = Math.max(maxCourseWeek, c.weeks[c.weeks.length - 1]); });
+
+        return {
+            courses: courses,
+            timeSlots: (await fetchSectionTimes(weekTabs.firstWeekUrl)) || [],
+            totalWeeks: totalWeeks > 0 ? totalWeeks : Math.max(maxCourseWeek, 20),
+            title: title,
+            startDate: firstDates ? alignToMonday(fullDate(firstDates[0])) : '',
+            firstWeekText: firstDates ? fullDate(firstDates[0]) + ' ~ ' + fullDate(firstDates[6]) : '',
+            elapsedMs: Date.now() - started,
+        };
+    }
+
+    // ---------- 时间工具 ----------
+
+    // 接口只给 MM-DD,按当前日期补年份(跨年学期也能算对)
+    function fullDate(mmdd) {
+        var m = /^(\d{2})-(\d{2})$/.exec(norm(mmdd));
+        if (!m) return '';
+        var now = new Date();
+        var d = new Date(Date.UTC(now.getUTCFullYear(), Number(m[1]) - 1, Number(m[2])));
+        if (d.getTime() - now.getTime() > 180 * 24 * 3600 * 1000) d = new Date(Date.UTC(now.getUTCFullYear() - 1, Number(m[1]) - 1, Number(m[2])));
+        return d.getUTCFullYear() + '-' + pad2(d.getUTCMonth() + 1) + '-' + pad2(d.getUTCDate());
+    }
+
+    function alignToMonday(dateStr) {
+        var m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(dateStr || ''));
+        if (!m) return '';
+        var d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
+        var dow = d.getUTCDay();
+        d.setUTCDate(d.getUTCDate() + (dow === 0 ? -6 : 1 - dow));
+        return d.getUTCFullYear() + '-' + pad2(d.getUTCMonth() + 1) + '-' + pad2(d.getUTCDate());
+    }
+
+    // ---------- 保存 ----------
+
+    async function saveConfig(config) {
+        await BridgePromise.saveCourseConfig(JSON.stringify(config));
+    }
+
+    async function saveCourses(courses) {
+        await BridgePromise.saveImportedCourses(JSON.stringify(courses));
+    }
+
+    async function saveTimeSlots(slots) {
+        if (slots && slots.length) await BridgePromise.savePresetTimeSlots(JSON.stringify(slots));
+    }
+
+    // ---------- 流程编排 ----------
+
+    // 取数 → 保存 → 一次汇总弹窗;任何失败都直接弹出错误并终止
+    async function runImportFlow() {
+        if (!Bridge || !BridgePromise) {
+            console.error('未找到桥接对象,请确认运行在拾光课程表的 WebView / 测试插件里。');
+            return;
+        }
+        toast('正在获取课表…');
+        try {
+            var data = await fetchAll();
+
+            await saveConfig({ semesterStartDate: data.startDate, semesterTotalWeeks: data.totalWeeks });
+            await saveCourses(data.courses);
+            try { await saveTimeSlots(data.timeSlots); } catch (e) {}
+
+            var names = {};
+            data.courses.forEach(function (c) { names[c.name] = 1; });
+            await BridgePromise.showAlert(
+                '导入完成',
+                '账号:' + (data.title || '(未返回)') + '\n'
+                    + '第 1 周:' + (data.firstWeekText || '(未返回日期)') + '\n'
+                    + '学期:共 ' + data.totalWeeks + ' 周\n'
+                    + '课程:' + data.courses.length + ' 条(' + Object.keys(names).length + ' 门)\n'
+                    + '作息:' + (data.timeSlots.length ? data.timeSlots.length + ' 节' : '未取到(已跳过时间段导入)') + '\n'
+                    + '用时:' + (data.elapsedMs / 1000).toFixed(1) + 's',
+                '好的',
+            );
+            Bridge.notifyTaskCompletion();
+        } catch (error) {
+            var msg = String(error && error.message ? error.message : error);
+            toast('导入失败:' + msg);
+            await BridgePromise.showAlert('导入失败', msg, '知道了');
+        }
+    }
+
+    runImportFlow();
+})();

+ 242 - 45
resources/JNMC/jnmc.js

@@ -6,58 +6,140 @@ const PRESET_TIME_SLOTS = [
     { number: 3, startTime: "09:50", endTime: "10:30" },
     { number: 4, startTime: "10:40", endTime: "11:20" },
     { number: 5, startTime: "11:30", endTime: "12:10" },
-    { number: 6, startTime: "14:30", endTime: "15:10" },
-    { number: 7, startTime: "15:20", endTime: "16:00" },
-    { number: 8, startTime: "16:20", endTime: "17:00" },
-    { number: 9, startTime: "17:10", endTime: "17:50" },
-    { number: 10, startTime: "18:00", endTime: "18:40" },
-    { number: 11, startTime: "19:30", endTime: "20:10" },
-    { number: 12, startTime: "20:20", endTime: "21:00" }
+    { number: 6, startTime: "14:00", endTime: "14:40" },
+    { number: 7, startTime: "14:50", endTime: "15:30" },
+    { number: 8, startTime: "15:50", endTime: "16:30" },
+    { number: 9, startTime: "16:40", endTime: "17:20" },
+    { number: 10, startTime: "17:30", endTime: "18:10" },
+    { number: 11, startTime: "19:00", endTime: "19:40" },
+    { number: 12, startTime: "19:50", endTime: "20:30" },
+    { number: 13, startTime: "20:50", endTime: "21:30" },
+    { number: 14, startTime: "21:40", endTime: "22:20" }
 ];
 
-// 周次字符串 "10,7,8,9" → 去重排序的周数组
+function uniqueSortedNumbers(numbers) {
+    return [...new Set(numbers)].sort((a, b) => a - b);
+}
+
+// 支持 "10,7,8,9"、"1-4"、"1-4,7" 等周次写法。
 function parseWeeks(weekStr) {
     if (!weekStr) return [];
-    const weeks = weekStr.split(",").map(w => parseInt(w.trim(), 10)).filter(w => !isNaN(w) && w > 0);
-    return [...new Set(weeks)].sort((a, b) => a - b);
+    const weeks = [];
+    String(weekStr).replace(/[,、]/g, ",").split(",").forEach(part => {
+        const text = part.trim().replace(/周/g, "");
+        const range = text.match(/^(\d+)\s*[-~]\s*(\d+)$/);
+        if (range) {
+            const start = parseInt(range[1], 10);
+            const end = parseInt(range[2], 10);
+            if (start > 0 && end >= start) {
+                for (let week = start; week <= end; week += 1) weeks.push(week);
+            }
+            return;
+        }
+        const week = parseInt(text, 10);
+        if (!isNaN(week) && week > 0) weeks.push(week);
+    });
+    return uniqueSortedNumbers(weeks);
 }
 
-// 教室可能为空、含 "\\" 分隔的多教室(如 "B205\206教室")或以 "," 分隔的多场地
+function parseSections(sectionStr) {
+    return uniqueSortedNumbers(
+        String(sectionStr || "")
+            .replace(/[,、]/g, ",")
+            .split(",")
+            .map(section => parseInt(section.trim(), 10))
+            .filter(section => !isNaN(section) && section > 0)
+    );
+}
+
+// 反斜杠表示合并教室(如 "B205\206教室"),逗号才是不同地点的分隔符。
 function resolvePosition(raw) {
-    const position = String(raw || "").replace(/\\/g, "/").trim();
+    const position = String(raw || "").replace(/\\/g, "/").replace(/\s+/g, " ").trim();
     return position || "待定";
 }
 
-// kbxx 课程 JSON → 拾光课程格式(合并同 key 课程的周次)
+function cleanTeacher(raw) {
+    const teacher = String(raw || "")
+        .split(/[,,、]/)
+        .map(name => name.trim())
+        .filter(Boolean)
+        .join("、");
+    return teacher || "未知";
+}
+
+function courseKey(course) {
+    return [
+        course.name,
+        course.teacher,
+        course.position,
+        course.day,
+        course.startSection,
+        course.endSection
+    ].join("__");
+}
+
+function addCourse(courseMap, item, weeks) {
+    const day = parseInt(item.xq, 10);
+    const sections = parseSections(item.jcdm2);
+    const allWeeks = uniqueSortedNumbers(weeks);
+    if (!item.kcmc || sections.length === 0 || allWeeks.length === 0 ||
+        isNaN(day) || day < 1 || day > 7) return;
+
+    const course = {
+        name: String(item.kcmc).trim(),
+        teacher: cleanTeacher(item.teaxms),
+        position: resolvePosition(item.jxcdmc || item.jxcdmcs),
+        day,
+        startSection: Math.min(...sections),
+        endSection: Math.max(...sections),
+        weeks: allWeeks
+    };
+
+    const key = courseKey(course);
+    const existing = courseMap.get(key);
+    if (existing) {
+        existing.weeks = uniqueSortedNumbers([...existing.weeks, ...course.weeks]);
+    } else {
+        courseMap.set(key, course);
+    }
+}
+
+function sortCourses(courses) {
+    return courses.sort((a, b) =>
+        a.day - b.day ||
+        a.startSection - b.startSection ||
+        a.endSection - b.endSection ||
+        a.name.localeCompare(b.name)
+    );
+}
+
+// 从整学期课表解析课程,作为周课表接口不可用时的回退。
 function parseCourseList(kbxx) {
     if (!Array.isArray(kbxx)) throw new Error("课表接口返回格式不正确");
     const courseMap = new Map();
     kbxx.forEach(item => {
-        const day = parseInt(item.xq, 10);
-        const sections = String(item.jcdm2 || "").split(",")
-            .map(s => parseInt(s.trim(), 10)).filter(s => !isNaN(s));
-        const allWeeks = parseWeeks(item.zcs);
-        if (!item.kcmc || sections.length === 0 || allWeeks.length === 0 || isNaN(day) || day < 1 || day > 7) return;
-
-        const course = {
-            name: item.kcmc.trim(),
-            teacher: String(item.teaxms || "").trim() || "未知",
-            position: resolvePosition(item.jxcdmcs),
-            day,
-            startSection: Math.min(...sections),
-            endSection: Math.max(...sections),
-            weeks: allWeeks
-        };
+        addCourse(courseMap, item, parseWeeks(item.zcs));
+    });
+    return sortCourses(Array.from(courseMap.values()));
+}
 
-        const key = [course.name, course.teacher, course.position, course.day,
-            course.startSection, course.endSection].join("__");
-        const existing = courseMap.get(key);
-        if (existing) existing.weeks = [...new Set([...existing.weeks, ...course.weeks])].sort((a, b) => a - b);
-        else courseMap.set(key, course);
+// 日历接口按周返回精确教室;同一课程换教室时,按周次拆成多个课程项。
+function parseWeeklyCourseList(rows) {
+    if (!Array.isArray(rows)) return [];
+    const courseMap = new Map();
+    rows.forEach(({ item, week }) => {
+        const actualWeek = parseInt(week, 10);
+        if (isNaN(actualWeek) || actualWeek < 1) return;
+        addCourse(courseMap, item, [actualWeek]);
     });
-    return Array.from(courseMap.values()).sort((a, b) =>
-        a.day - b.day || a.startSection - b.startSection || a.endSection - b.endSection || a.name.localeCompare(b.name)
-    );
+    return sortCourses(Array.from(courseMap.values()));
+}
+
+function maxWeekOf(kbxx) {
+    return kbxx.reduce((maxWeek, item) => {
+        const weeks = parseWeeks(item.zcs);
+        return Math.max(maxWeek, ...(weeks.length ? weeks : [0]));
+    }, 0);
 }
 
 // 读取课表页中的学期下拉框
@@ -86,11 +168,16 @@ function extractSemesterOptions(doc) {
 
 // 导入前提示先登录教务系统
 async function promptUserToStart() {
-    return await window.shiguangBridgePromise.showAlert(
+    const confirmed = await window.shiguangBridgePromise.showAlert(
         "济宁医学院教务导入",
         "请先确保已登录教务系统,再继续导入。",
         "我已登录"
     );
+    if (!confirmed) {
+        window.shiguangBridge.showToast("用户取消了导入。");
+        return null;
+    }
+    return true;
 }
 
 // 选择学期
@@ -100,10 +187,12 @@ async function selectSemester(semesterOptions) {
         JSON.stringify(semesterOptions.semesters),
         semesterOptions.defaultIndex
     );
-    if (selectedIndex === null || selectedIndex < 0) return null;
+    if (selectedIndex === null || selectedIndex === undefined) return null;
+    const index = Number(selectedIndex);
+    if (!Number.isInteger(index) || index < 0 || index >= semesterOptions.semesters.length) return null;
     return {
-        label: semesterOptions.semesters[selectedIndex],
-        value: semesterOptions.semesterValues[selectedIndex]
+        label: semesterOptions.semesters[index],
+        value: semesterOptions.semesterValues[index]
     };
 }
 
@@ -133,11 +222,105 @@ async function fetchCourseData(xnxqdm) {
     return kbxx;
 }
 
+function normalizeDate(raw) {
+    const match = String(raw || "").match(/^(\d{4}-\d{2}-\d{2})/);
+    return match ? match[1] : null;
+}
+
+function shiftDate(dateText, dayOffset) {
+    const match = String(dateText || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
+    if (!match) return null;
+    const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
+    date.setDate(date.getDate() + dayOffset);
+    return [
+        date.getFullYear(),
+        String(date.getMonth() + 1).padStart(2, "0"),
+        String(date.getDate()).padStart(2, "0")
+    ].join("-");
+}
+
+function extractWeekStartDate(payload) {
+    const dateRows = Array.isArray(payload) && Array.isArray(payload[1]) ? payload[1] : [];
+    const monday = dateRows.find(item => parseInt(item.xqmc, 10) === 1) || dateRows[0];
+    return normalizeDate(monday && monday.rq);
+}
+
+// 获取某一周的日历数据,jxcdmc 是该周实际使用的教室。
+async function fetchWeeklyCourseData(xnxqdm, week) {
+    const response = await fetch(
+        `/xsgrkbcx!getKbRq.action?xnxqdm=${encodeURIComponent(xnxqdm)}&zc=${encodeURIComponent(week)}`,
+        { method: "GET", credentials: "include" }
+    );
+    if (!response.ok) throw new Error(`第${week}周课表请求失败(HTTP ${response.status})`);
+    const payload = await response.json();
+    const rows = Array.isArray(payload) && Array.isArray(payload[0]) ? payload[0] : [];
+    return {
+        rows: rows.map(item => ({ item, week })),
+        weekStartDate: extractWeekStartDate(payload)
+    };
+}
+
+async function fetchWeeklyCourses(xnxqdm, kbxx) {
+    const weeks = Array.from({ length: maxWeekOf(kbxx) }, (_, index) => index + 1);
+    if (weeks.length === 0) return { rows: [], successfulWeeks: [], semesterStartDate: null };
+
+    const results = await Promise.all(weeks.map(async week => {
+        try {
+            const data = await fetchWeeklyCourseData(xnxqdm, week);
+            return { week, ...data };
+        } catch (error) {
+            console.warn(`读取第${week}周课表失败`, error);
+            return { week, rows: null, weekStartDate: null };
+        }
+    }));
+
+    const datedWeek = results.find(result => result.weekStartDate);
+    const semesterStartDate = datedWeek
+        ? shiftDate(datedWeek.weekStartDate, -7 * (datedWeek.week - 1))
+        : null;
+    return {
+        rows: results.flatMap(result => result.rows || []),
+        successfulWeeks: results.filter(result => result.rows !== null).map(result => result.week),
+        semesterStartDate
+    };
+}
+
+function mergeWeeklyAndFallbackCourses(kbxx, weeklyData) {
+    if (weeklyData.rows.length === 0) return parseCourseList(kbxx);
+
+    const courseMap = new Map();
+    parseWeeklyCourseList(weeklyData.rows).forEach(course => {
+        courseMap.set(courseKey(course), course);
+    });
+    const successfulWeeks = new Set(weeklyData.successfulWeeks);
+    if (successfulWeeks.size === weeklyData.successfulWeeks.length &&
+        successfulWeeks.size >= maxWeekOf(kbxx)) return sortCourses(Array.from(courseMap.values()));
+
+    // 仅为请求失败的周次使用整学期数据,避免覆盖周课表中的精确教室。
+    kbxx.forEach(item => {
+        const weeks = parseWeeks(item.zcs).filter(week => !successfulWeeks.has(week));
+        if (weeks.length > 0) addCourse(courseMap, item, weeks);
+    });
+    return sortCourses(Array.from(courseMap.values()));
+}
+
+async function saveCourseConfig(config) {
+    await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+}
+
+async function saveCourses(courses) {
+    await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+}
+
+async function savePresetTimeSlots() {
+    await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(PRESET_TIME_SLOTS));
+}
+
 // 主流程:提示 → 选学期 → 拉课表 → 保存课程与作息
 async function runImportFlow() {
     try {
-        const confirmed = await promptUserToStart();
-        if (!confirmed) { window.shiguangBridge.showToast("导入已取消"); return; }
+        const alertConfirmed = await promptUserToStart();
+        if (!alertConfirmed) return;
 
         const pageHtml = await fetchSchedulePage();
         const semesterOptions = extractSemesterOptions(new DOMParser().parseFromString(pageHtml, "text/html"));
@@ -147,7 +330,9 @@ async function runImportFlow() {
         if (!semester) { window.shiguangBridge.showToast("导入已取消"); return; }
 
         window.shiguangBridge.showToast(`正在获取 ${semester.label} 的课表...`);
-        const courses = parseCourseList(await fetchCourseData(semester.value));
+        const allCourseData = await fetchCourseData(semester.value);
+        const weeklyData = await fetchWeeklyCourses(semester.value, allCourseData);
+        const courses = mergeWeeklyAndFallbackCourses(allCourseData, weeklyData);
 
         if (courses.length === 0) {
             await window.shiguangBridgePromise.showAlert(
@@ -158,14 +343,26 @@ async function runImportFlow() {
             return;
         }
 
-        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+        const courseConfig = {
+            semesterTotalWeeks: Math.max(maxWeekOf(allCourseData), 20),
+            firstDayOfWeek: 1
+        };
+        if (weeklyData.semesterStartDate) {
+            courseConfig.semesterStartDate = weeklyData.semesterStartDate;
+        }
+
+        await saveCourseConfig(courseConfig);
+        await saveCourses(courses);
         try {
-            await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(PRESET_TIME_SLOTS));
+            await savePresetTimeSlots();
         } catch (error) {
             window.shiguangBridge.showToast(`课程已导入,作息时间导入失败:${error.message}`);
         }
 
         window.shiguangBridge.showToast(`成功导入 ${courses.length} 门课程!`);
+        if (!weeklyData.semesterStartDate) {
+            window.shiguangBridge.showToast("未获取到开学日期,请在应用内手动设置。");
+        }
         window.shiguangBridge.notifyTaskCompletion();
     } catch (error) {
         await window.shiguangBridgePromise.showAlert(