소스 검색

Merge pull request #815 from ShiGuangSchedule/pending

星河欲转 4 주 전
부모
커밋
1e54fcdb78
4개의 변경된 파일과 562개의 추가작업 그리고 168개의 파일을 삭제
  1. 1 1
      resources/TSU/adapters.yaml
  2. 331 126
      resources/TSU/tsu_01.js
  3. 1 1
      resources/UJS/adapters.yaml
  4. 229 40
      resources/UJS/ujs_yjs_v1.0.js

+ 1 - 1
resources/TSU/adapters.yaml

@@ -6,4 +6,4 @@ adapters:
     asset_js_path: "tsu_01.js"
     import_url: "http://jwxt.tsu.edu.cn"
     maintainer: "素绫理"
-    description: "泰山学院青果教务系统课表适配,登陆后点击左侧【教学安排】,再点击【执行导入】即可,导入后不要忘记设置本学期的开学日期"
+    description: "泰山学院青果教务系统课表适配,登陆教务系统后在任意页面点击【执行导入】即可(课表数据经接口获取,不依赖当前页面)。导入时可选择学期,开学日期自动获取;脚本会自动提交夏/冬令时组合作息(夏令时5月1日–10月7日,冬令时10月8日–次年4月30日)"

+ 331 - 126
resources/TSU/tsu_01.js

@@ -1,4 +1,12 @@
-const SECTION_TIMES = [
+// 依赖接口:
+//   POST /frame/droplist/getDropLists.action                     学期列表(JSON)
+//   GET  /student/xkjg.wdkb.jsp?menucode=S20301                  内部学号 xh(GBK HTML)
+//   GET  /student/wsxk.xskcb10319.jsp?params=base64              课表数据(GBK HTML)
+//   POST /frame/desk/showLessonScheduleInfosV14.action           第一周周一日期
+
+// ===== 作息时间 =====
+// 夏令时(每年 5月1日–10月7日):作为基础时间段(骨架)提交
+const SUMMER_TIME_SLOTS = [
     { number: 1,  startTime: "08:00", endTime: "08:45" },
     { number: 2,  startTime: "08:55", endTime: "09:40" },
     { number: 3,  startTime: "10:00", endTime: "10:45" },
@@ -8,135 +16,138 @@ const SECTION_TIMES = [
     { number: 7,  startTime: "16:30", endTime: "17:15" },
     { number: 8,  startTime: "17:25", endTime: "18:10" },
     { number: 9,  startTime: "19:00", endTime: "19:45" },
-    { number: 10, startTime: "19:55", endTime: "20:40" },
+    { number: 10, startTime: "19:55", endTime: "20:40" }
 ];
 
-function parseWeeks(weekStr, parity) {
-    const weeks = [];
-    weekStr.split(',').forEach(part => {
-        if (part.includes('-')) {
-            const [start, end] = part.split('-').map(Number);
-            for (let i = start; i <= end; i++) weeks.push(i);
-        } else {
-            const w = parseInt(part);
-            if (!isNaN(w)) weeks.push(w);
-        }
-    });
-    if (parity === '单') return weeks.filter(w => w % 2 === 1);
-    if (parity === '双') return weeks.filter(w => w % 2 === 0);
-    return weeks;
+// 冬令时(每年 10月8日–次年4月30日)差量:仅下午 5–8 节整体提前 30 分钟,
+// 上午 1–4 节与晚间 9–10 节由骨架自动补齐
+const WINTER_DIFF_SLOTS = [
+    { 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" }
+];
+
+// ===== 日期工具 =====
+function formatDate(date) {
+    const year = date.getFullYear();
+    const month = String(date.getMonth() + 1).padStart(2, "0");
+    const day = String(date.getDate()).padStart(2, "0");
+    return `${year}-${month}-${day}`;
 }
 
-function findTable(win) {
-    const t = Array.from(win.document.querySelectorAll('table'))
-        .find(x => x.innerText.includes("星期一") && x.innerText.includes("["));
-    if (t) return t;
-    for (let i = 0; i < win.frames.length; i++) {
-        try {
-            const st = findTable(win.frames[i]);
-            if (st) return st;
-        } catch (e) {}
-    }
-    return null;
+// 夏令时:本年 5月1日–10月7日;冬令时:10月8日–次年4月30日(1–4月导入时自上一年起算)
+function buildComboDateRanges() {
+    const now = new Date();
+    const year = now.getFullYear();
+    const month = now.getMonth() + 1;
+    const winterStartYear = month <= 4 ? year - 1 : year;
+    return {
+        summer: { startDate: `${year}-05-01`, endDate: `${year}-10-07` },
+        winter: { startDate: `${winterStartYear}-10-08`, endDate: `${winterStartYear + 1}-04-30` }
+    };
+}
+
+// 开学日期弹窗默认值的兜底:本周周一(周日归上一周)
+function getMondayOfCurrentWeek() {
+    const now = new Date();
+    const offset = now.getDay() === 0 ? -6 : 1 - now.getDay();
+    const monday = new Date(now);
+    monday.setDate(now.getDate() + offset);
+    return formatDate(monday);
 }
 
-async function fetchAndParseCourses() {
-    const table = findTable(window);
-    if (!table) {
-        throw new Error("未检测到课表数据,请确保已切换到显示课表的页面!");
+// showPrompt 的全局验证函数:false=通过,字符串=错误文案
+function validateDateInput(input) {
+    if (!/^\d{4}-\d{2}-\d{2}$/.test(input)) {
+        return "请输入 YYYY-MM-DD 格式的日期!";
+    }
+    const date = new Date(input + "T00:00:00");
+    if (isNaN(date.getTime()) || formatDate(date) !== input) {
+        return "该日期不存在,请检查!";
     }
+    return false;
+}
 
+// ===== 周次解析(支持 12-16 / 8 / 1-8单 / 9-17双 / 1,3,5)=====
+function parseWeeks(weekStr) {
+    const weeks = [];
+    weekStr.split(",").forEach(group => {
+        const g = group.trim();
+        const isSingle = g.includes("单");
+        const isDouble = g.includes("双");
+        const rangeMatch = g.match(/(\d+)\s*-\s*(\d+)/);
+        if (rangeMatch) {
+            for (let i = parseInt(rangeMatch[1]); i <= parseInt(rangeMatch[2]); i++) {
+                if (isSingle && i % 2 === 0) continue;
+                if (isDouble && i % 2 !== 0) continue;
+                weeks.push(i);
+            }
+        } else {
+            const num = parseInt(g.replace(/[^\d]/g, ""));
+            if (!isNaN(num)) weeks.push(num);
+        }
+    });
+    return Array.from(new Set(weeks)).sort((a, b) => a - b);
+}
+
+// ===== 课表 HTML 解析 =====
+// 输入:xskcb10319.jsp 返回的 GBK 解码后 HTML
+// 结构:每个星期列是 td.td;每门课一个 <div style='padding-bottom:5px...'>,
+//       div 内按 <br> 分行依次为 课程名/教师/周次[起止节次]/地点
+function parseCourseTableHtml(htmlText) {
+    const doc = new DOMParser().parseFromString(htmlText, "text/html");
     const rawItems = [];
-    Array.from(table.rows).forEach(row => {
-        const cells = Array.from(row.cells);
-        if (cells.length < 7) return;
-
-        cells.forEach((cell, colIndex) => {
-            const distanceToLast = cells.length - 1 - colIndex;
-            if (distanceToLast > 6) return;
-            const day = 7 - distanceToLast;
-            const rawText = cell.innerText.trim();
-            if (!rawText.includes('[')) return;
-
-            // 过滤空行、清理隐藏的乱码字符
-            const lines = rawText.split('\n').map(l => l.replace(/[\s\u200B-\u200D\uFEFF]/g, '').trim()).filter(l => l);
-
-            // 找出所有时间的行号(锚点)
-            const timeIndices = [];
-            lines.forEach((line, index) => {
-                if (/([\d\-,]+)\s*(?:单|双)?\s*\[(\d+)-(\d+)\]/.test(line)) {
-                    timeIndices.push(index);
-                }
-            });
 
-            timeIndices.forEach((currTimeIdx, k) => {
-                const nextTimeIdx = (k + 1 < timeIndices.length) ? timeIndices[k + 1] : lines.length;
-                const match = lines[currTimeIdx].match(/([\d\-,]+)\s*(单|双)?\s*\[(\d+)-(\d+)\]/);
-
-                if (match) {
-                    // 1. 抓取课名和老师
-                    let name = "未知课程";
-                    let teacher = "未知教师";
-                    let nameTeacherLines = (k === 0) ? lines.slice(0, currTimeIdx) : lines.slice(timeIndices[k - 1] + 1, currTimeIdx);
-
-                    if (nameTeacherLines.length > 2) {
-                        nameTeacherLines = nameTeacherLines.slice(nameTeacherLines.length - 2);
-                    }
-
-                    if (nameTeacherLines.length >= 2) {
-                        name = nameTeacherLines[0];
-                        teacher = nameTeacherLines[1];
-                    } else if (nameTeacherLines.length === 1) {
-                        name = nameTeacherLines[0];
-                        teacher = "";
-                    }
-
-                    // 2. 抓取地点
-                    let position = "";
-                    const gap = nextTimeIdx - currTimeIdx - 1;
-
-                    if (k === timeIndices.length - 1) {
-                        if (gap > 0) position = lines.slice(currTimeIdx + 1).join(' ');
-                    } else {
-                        if (gap === 3) {
-                            position = lines[currTimeIdx + 1];
-                        } else if (gap > 3) {
-                            position = lines.slice(currTimeIdx + 1, nextTimeIdx - 2).join(' ');
-                        } else {
-                            position = "";
-                        }
-                    }
-
-                    rawItems.push({
-                        name: name,
-                        teacher: teacher,
-                        position: position,
-                        day: day,
-                        startSection: parseInt(match[3]),
-                        endSection: parseInt(match[4]),
-                        weeks: parseWeeks(match[1], match[2])
-                    });
-                }
+    doc.querySelectorAll("tr").forEach(row => {
+        const cells = row.querySelectorAll("td.td");
+        cells.forEach((cell, dayIndex) => {
+            const day = dayIndex + 1; // 一行内 td.td 依次对应 星期一~星期日
+            const divs = cell.querySelectorAll("div[style*='padding-bottom:5px']");
+            divs.forEach(div => {
+                const lines = Array.from(div.childNodes)
+                    .map(n => n.textContent.trim())
+                    .filter(t => t.length > 0);
+                if (lines.length < 3) return;
+                const timeMatch = lines[2].match(/^(.+?)\s*\[(\d+)-(\d+)\]\s*$/);
+                if (!timeMatch) return;
+                rawItems.push({
+                    name: lines[0],
+                    teacher: lines[1] || "",
+                    position: lines[3] || "未知地点",
+                    day: day,
+                    startSection: parseInt(timeMatch[2]),
+                    endSection: parseInt(timeMatch[3]),
+                    weeks: parseWeeks(timeMatch[1])
+                });
             });
         });
     });
 
+    // 完全重复项去重(青果网格可能对跨节次课程重复渲染)
+    const seen = new Set();
+    const uniqueItems = rawItems.filter(item => {
+        const key = `${item.name}|${item.teacher}|${item.position}|${item.day}|${item.startSection}|${item.endSection}|${item.weeks.join(",")}`;
+        if (seen.has(key)) return false;
+        seen.add(key);
+        return true;
+    });
+
+    // 合并去重:同(名|师|地|星期)按周次展开为节次矩阵,再按连续节次聚合
     const groupMap = new Map();
-    rawItems.forEach(item => {
+    uniqueItems.forEach(item => {
         const key = `${item.name}|${item.teacher}|${item.position}|${item.day}`;
         if (!groupMap.has(key)) groupMap.set(key, {});
         const weekMap = groupMap.get(key);
         item.weeks.forEach(w => {
             if (!weekMap[w]) weekMap[w] = new Set();
-            for (let s = item.startSection; s <= item.endSection; s++) {
-                weekMap[w].add(s);
-            }
+            for (let s = item.startSection; s <= item.endSection; s++) weekMap[w].add(s);
         });
     });
 
     const finalCourses = [];
     groupMap.forEach((weekMap, key) => {
-        const [name, teacher, position, day] = key.split('|');
+        const [name, teacher, position, day] = key.split("|");
         const patternMap = new Map();
         Object.keys(weekMap).forEach(w => {
             const week = parseInt(w);
@@ -144,49 +155,243 @@ async function fetchAndParseCourses() {
             if (sections.length === 0) return;
             let start = sections[0];
             for (let i = 0; i < sections.length; i++) {
-                if (i === sections.length - 1 || sections[i+1] !== sections[i] + 1) {
+                if (i === sections.length - 1 || sections[i + 1] !== sections[i] + 1) {
                     const pKey = `${start}-${sections[i]}`;
-                    if (!patternMap.has(pKey)) patternMap.set(pKey, []);
-                    patternMap.get(pKey).push(week);
-                    if (i < sections.length - 1) start = sections[i+1];
+                    if (!patternMap.has(pKey)) patternMap.set(pKey, new Set());
+                    patternMap.get(pKey).add(week);
+                    if (i < sections.length - 1) start = sections[i + 1];
                 }
             }
         });
-        patternMap.forEach((weeks, pKey) => {
-            const [sStart, sEnd] = pKey.split('-').map(Number);
+        patternMap.forEach((weekSet, pKey) => {
+            const [sStart, sEnd] = pKey.split("-").map(Number);
             finalCourses.push({
                 name, teacher, position,
                 day: parseInt(day),
                 startSection: sStart,
                 endSection: sEnd,
-                weeks: weeks.sort((a, b) => a - b)
+                weeks: Array.from(weekSet).sort((a, b) => a - b)
             });
         });
     });
     return finalCourses;
 }
 
+// ===== 接口封装 =====
+const JWXT_BASE = window.location.origin;
+
+// 学期列表 → 用户选择 → { xn, xq, name }
+async function selectSemester() {
+    window.shiguangBridge.showToast("正在获取学期列表...");
+    const resp = await fetch(JWXT_BASE + "/frame/droplist/getDropLists.action", {
+        method: "POST",
+        headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
+        body: "comboBoxName=StMsXnxqDxDesc&paramValue=&isYXB=0&isCDDW=0&isXQ=0&isDJKSLB=0&isZY=0",
+        credentials: "include"
+    });
+    const list = await resp.json();
+    if (!Array.isArray(list) || list.length === 0) throw new Error("学期列表为空");
+    const names = list.map(item => item.name);
+    const selectedIndex = await window.shiguangBridgePromise.showSingleSelection("选择导入学期", JSON.stringify(names), 0);
+    if (selectedIndex === null) return null;
+    const [xn, xq] = list[selectedIndex].code.split("-");
+    return { xn, xq, name: list[selectedIndex].name };
+}
+
+// 内部学号(≠登录账号):wdkb.jsp 的隐藏域,实时 DOM 兜底
+async function getInternalStudentId() {
+    const resp = await fetch(JWXT_BASE + "/student/xkjg.wdkb.jsp?menucode=S20301", { credentials: "include" });
+    const html = new TextDecoder("gbk").decode(await resp.arrayBuffer());
+    const tagMatch = html.match(/<input[^>]*id=["']xh["'][^>]*>/i);
+    if (tagMatch) {
+        const valueMatch = tagMatch[0].match(/value=["']([^"']+)["']/i);
+        if (valueMatch) return valueMatch[1];
+    }
+    let fromDom = null;
+    (function walk(win) {
+        if (fromDom) return;
+        try {
+            const el = win.document.getElementById("xh");
+            if (el && el.value) { fromDom = el.value; return; }
+        } catch (e) {}
+        for (let i = 0; i < win.frames.length; i++) { try { walk(win.frames[i]); } catch (e) {} }
+    })(window);
+    return fromDom;
+}
+
+// 课表 HTML(GBK)
+async function fetchCourseHtml(xn, xq, xh) {
+    const params = btoa(`xn=${xn}&xq=${xq}&xh=${xh}`);
+    const resp = await fetch(`${JWXT_BASE}/student/wsxk.xskcb10319.jsp?params=${params}`, { credentials: "include" });
+    return new TextDecoder("gbk").decode(await resp.arrayBuffer());
+}
+
+// 第一周周一日期(教学日历接口,jxz=1 即第 1 周,周一列头日期即开学日期)
+// 注意:泰山该接口返回 UTF-8(课表接口才是 GBK);失败返回 null,由调用方兜底
+async function fetchSemesterStartDate(xn, xq) {
+    const resp = await fetch(`${JWXT_BASE}/frame/desk/showLessonScheduleInfosV14.action?xn=${xn}&xq=${xq}&jxz=1`, {
+        method: "POST",
+        headers: { "x-requested-with": "XMLHttpRequest" },
+        credentials: "include"
+    });
+    const html = await resp.text();
+    const match = html.match(/<br\s*\/?>\s*(\d{2})-(\d{2})/);
+    if (!match) return null;
+    const year = xq === "1" ? String(parseInt(xn) + 1) : String(xn);
+    return `${year}-${match[1]}-${match[2]}`;
+}
+
+// ===== 导入步骤 =====
+async function saveCourseConfig(startDate) {
+    try {
+        await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
+            semesterStartDate: startDate
+        }));
+        window.shiguangBridge.showToast("开学日期已保存");
+        return true;
+    } catch (error) {
+        window.shiguangBridge.showToast("保存开学日期失败: " + error.message);
+        return false;
+    }
+}
+
+async function importTimeSlots() {
+    try {
+        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(SUMMER_TIME_SLOTS));
+        return true;
+    } catch (error) {
+        window.shiguangBridge.showToast("导入时间段失败: " + error.message);
+        return false;
+    }
+}
+
+async function importComboSchedule() {
+    const ranges = buildComboDateRanges();
+    const comboSchedule = {
+        name: "泰山学院夏冬作息方案",
+        publicSchedules: [
+            {
+                name: "夏令时",
+                startDate: ranges.summer.startDate,
+                endDate: ranges.summer.endDate,
+                timeSlots: [] // 与骨架一致,全部由骨架补齐
+            },
+            {
+                name: "冬令时",
+                startDate: ranges.winter.startDate,
+                endDate: ranges.winter.endDate,
+                timeSlots: WINTER_DIFF_SLOTS // 仅提交下午 5–8 节差异
+            }
+        ]
+    };
+
+    try {
+        await window.shiguangBridgePromise.saveComboSchedule(JSON.stringify(comboSchedule));
+        return true;
+    } catch (error) {
+        window.shiguangBridge.showToast("组合作息导入失败: " + error.message);
+        return false;
+    }
+}
+
+// ===== 流程编排 =====
+// notifyTaskCompletion() 只在成功后调用;任何取消/失败立即终止
 async function runImportFlow() {
+    const confirmed = await window.shiguangBridgePromise.showAlert(
+        "青果教务导入",
+        "将从教务接口直接获取课表数据,请确保已在当前浏览器登录教务系统。",
+        "开始导入"
+    );
+    if (!confirmed) {
+        window.shiguangBridge.showToast("导入已取消。");
+        return;
+    }
+
+    // 1. 学期列表 + 用户选择(列表拉取失败大概率是未登录)
+    let semester;
     try {
-        window.shiguangBridge.showToast("泰山学院引擎启动,抓取数据中...");
-        const courses = await fetchAndParseCourses();
-        if (!courses || courses.length === 0) {
-            window.shiguangBridge.showToast("解析完成,但当前课表为空");
-            window.shiguangBridge.notifyTaskCompletion();
-            return;
-        }
+        semester = await selectSemester();
+    } catch (error) {
+        window.shiguangBridge.showToast("获取学期列表失败,请确认已登录教务系统");
+        return;
+    }
+    if (semester === null) {
+        window.shiguangBridge.showToast("导入已取消。");
+        return;
+    }
 
-        const saveResult = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
-        if (saveResult !== true) return;
+    // 2. 内部学号
+    let studentId;
+    try {
+        studentId = await getInternalStudentId();
+    } catch (error) {
+        studentId = null;
+    }
+    if (!studentId) {
+        window.shiguangBridge.showToast("获取学号失败,请稍后重试或向维护者反馈");
+        return;
+    }
 
-        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(SECTION_TIMES));
+    // 3. 拉取并解析课表
+    window.shiguangBridge.showToast(`正在抓取「${semester.name}」课表数据...`);
+    let courses;
+    try {
+        const html = await fetchCourseHtml(semester.xn, semester.xq, studentId);
+        courses = parseCourseTableHtml(html);
+    } catch (error) {
+        window.shiguangBridge.showToast("导入失败:" + error.message);
+        return;
+    }
+    if (!courses || courses.length === 0) {
+        window.shiguangBridge.showToast("未解析到课程数据,请确认所选学期有课程安排");
+        return;
+    }
 
-        window.shiguangBridge.showToast(`导入大成功!合并生成 ${courses.length} 个课块`);
-        window.shiguangBridge.notifyTaskCompletion();
+    // 4. 开学日期:接口自动获取 → 用户确认(取消则终止)
+    let apiStartDate = null;
+    try {
+        apiStartDate = await fetchSemesterStartDate(semester.xn, semester.xq);
     } catch (error) {
-        window.shiguangBridge.showToast("⚠️ " + error.message);
-        window.shiguangBridge.notifyTaskCompletion();
+        apiStartDate = null;
     }
+    const startDate = await window.shiguangBridgePromise.showPrompt(
+        "确认开学日期",
+        "请确认本学期第一周周一的日期(用于校准周数)",
+        apiStartDate || getMondayOfCurrentWeek(),
+        "validateDateInput"
+    );
+    if (startDate === null) {
+        window.shiguangBridge.showToast("导入已取消。");
+        return;
+    }
+
+    // 5. 保存开学日期
+    const configSaved = await saveCourseConfig(startDate);
+    if (!configSaved) return;
+
+    // 6. 保存课程
+    try {
+        const saveResult = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+        if (saveResult !== true) {
+            window.shiguangBridge.showToast("导入失败:课程数据未能保存");
+            return;
+        }
+    } catch (error) {
+        window.shiguangBridge.showToast("导入失败:" + error.message);
+        return;
+    }
+
+    // 7. 时间段(骨架)→ 组合作息
+    const timeSlotSaved = await importTimeSlots();
+    if (timeSlotSaved) {
+        await importComboSchedule();
+    } else {
+        window.shiguangBridge.showToast("时间段导入失败,跳过组合作息。");
+    }
+
+    // 8. 全部成功
+    window.shiguangBridge.showToast(`导入成功,共 ${courses.length} 门课程,夏/冬令时将按日期自动切换`);
+    window.shiguangBridge.notifyTaskCompletion();
 }
 
 runImportFlow();

+ 1 - 1
resources/UJS/adapters.yaml

@@ -13,4 +13,4 @@ adapters: #固定字段
     asset_js_path: "ujs_yjs_v1.0.js"
     import_url: "http://yjsgll.ujs.edu.cn/pyxx/"
     maintainer: "洛初 Github@gongfuture"
-    description: "江苏大学研究生管理系统适配,仅支持个人课表的导入,通过统一身份认证登录  ver.1"
+    description: "江苏大学研究生管理系统适配,仅支持个人课表的导入,通过统一身份认证登录  ver.2"

+ 229 - 40
resources/UJS/ujs_yjs_v1.0.js

@@ -10,8 +10,18 @@
 // 单元格文本自带「第3-18周 单双周:星期二 上3,4 星期五 上1,2」,星期、节次、周次都能直接取到。
 // 作息时间取自课表页下方的作息表(上午/下午/晚上),由服务端按当前令时渲染,脚本原样导入,
 // 因此不再像本科脚本那样询问夏令时/冬令时,也不做楼栋匹配(研究生系统全校一套作息)。(后续看十一再调整)
+
+// 2026.10.07 第二版
+// 作息改为写入「组合作息方案」(comboSchedule):夏令时 / 冬令时两套作息按日期区间自动切换,
+// 不再从课表页抓取作息表,也不再需要用户手动选择令时。
+// 夏令时 4/7 起、冬令时 10/7 起,与学校历年切令时公告一致,写死在方案规则里。
+// 研究生全校作息统一(都在研究生楼一带),不存在本科那种按楼栋分叉的作息,
+// 因此可以完全依赖组合作息,课程不再逐条写自定义时间:翻到哪一周,应用就用那一周的日期去匹配规则。
+// 作息数据取自教务处公布的江大作息:上午三江楼(B)一套,下午/晚上本部(E)夏令时与冬令时各一套,两季上午相同。
 // 开学日期:研究生系统没有校历接口,改用「我的课表」接口 App_Ajax/GetkcHandler.ashx 逐周回查,
 // 找到某门课第一次出现的日期,再按(起始周-1)*7+(星期-1)反推开学日期;反推不到则跳过配置保存。
+// 组合作息的日期区间按「本学期起止日期 ∩ 各令时日期区间」生成,保证整个学期被规则完整覆盖;
+// 拿不到开学日期时回退为当年固定的 4/7–10/6、10/7–次年 4/6 两个区间。
 
 /**
  * 数组过滤(原生实现)。
@@ -311,44 +321,181 @@ function parseCoursesFromGrid(doc) {
 }
 
 /**
- * 解析课表页下方的作息表,得到按全天连续编号的预设时间段。
- * 上午 dgListSw / 下午 dgListXw / 晚上 dgListWs 三张表,
- * 每行形如「第一节课 8:00-8:45」;编号按上午、下午、晚上依次顺延,
- * 与课表文本里的连续节次编号一致。
+ * 研究生楼作息时间(江苏大学教务处公布的作息,两季对比如下)。
+ *
+ * 上午一年四季不变,与本科三江楼(B)那套一致;
+ * 下午/晚上分夏令时与冬令时两套,与本科本部(E)那套一致:
+ *   夏令时:下午 14:00 开始,晚上 19:00 开始;
+ *   冬令时:下午 13:30 开始,晚上 18:30 开始。
+ *
+ * 节次是全天连续编号(上午 1-4、下午 5-8、晚上 9-11),与课表文本里的「上3,4」「晚9,10,11」一致。
+ */
+const MorningTimeSlots = [
+    { number: 1, startTime: "08:00", endTime: "08:45" },
+    { number: 2, startTime: "08:55", endTime: "09:40" },
+    { number: 3, startTime: "10:10", endTime: "10:55" },
+    { number: 4, startTime: "11:05", endTime: "11:50" }
+];
+
+// 夏令时 下午
+const SummerAfternoonTimeSlots = [
+    { 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" }
+];
+
+// 夏令时 晚上
+const SummerEveningTimeSlots = [
+    { number: 9, startTime: "19:00", endTime: "19:45" },
+    { number: 10, startTime: "19:55", endTime: "20:40" },
+    { number: 11, startTime: "20:50", endTime: "21:35" }
+];
+
+// 冬令时 下午
+const WinterAfternoonTimeSlots = [
+    { number: 5, startTime: "13:30", endTime: "14:15" },
+    { number: 6, startTime: "14:25", endTime: "15:10" },
+    { number: 7, startTime: "15:30", endTime: "16:15" },
+    { number: 8, startTime: "16:25", endTime: "17:10" }
+];
+
+// 冬令时 晚上
+const WinterEveningTimeSlots = [
+    { number: 9, startTime: "18:30", endTime: "19:15" },
+    { number: 10, startTime: "19:25", endTime: "20:10" },
+    { number: 11, startTime: "20:20", endTime: "21:05" }
+];
+
+// 两季各一套完整作息(上午、下午、晚上依次顺延,共 11 节)
+const SummerTimeSlots = [...MorningTimeSlots, ...SummerAfternoonTimeSlots, ...SummerEveningTimeSlots];
+const WinterTimeSlots = [...MorningTimeSlots, ...WinterAfternoonTimeSlots, ...WinterEveningTimeSlots];
+
+/**
+ * 组合作息方案名称。
+ */
+const COMBO_SCHEDULE_NAME = "江苏大学研究生作息(夏令时 / 冬令时自动切换)";
+
+/**
+ * 判断当前是否处于夏令时作息。
+ * 学校历年都是 4 月 7 日起执行夏令时、10 月 7 日起执行冬令时(十一假期结束后切回)。
+ * 这里只用于决定「基础时间段」写入哪一套,真正生效的时间由组合作息按日期决定。
+ */
+function isSummerTimeNow() {
+    const now = new Date();
+    const summerStart = new Date(now.getFullYear(), 3, 7); // 4月7日
+    const winterStart = new Date(now.getFullYear(), 9, 7); // 10月7日
+    return now >= summerStart && now < winterStart;
+}
+
+/**
+ * 把日期字符串("YYYY-MM-DD")加减天数,返回同样格式的字符串。
  */
-function parseTimeSlots(doc) {
-    const groups = [
-        { id: "#MainWork_dgListSw", label: "上午" },
-        { id: "#MainWork_dgListXw", label: "下午" },
-        { id: "#MainWork_dgListWs", label: "晚上" }
-    ];
-
-    const slots = [];
-    let number = 1;
-
-    for (const group of groups) {
-        const table = doc.querySelector(group.id);
-        if (!table) continue;
-
-        for (const cell of Array.from(table.querySelectorAll("td"))) {
-            // 作息表是「单元格里再套一层表」的结构,外层单元格的 textContent 也含时间,
-            // 这里只取最内层单元格,避免同一条时间被重复计数。
-            if (cell.querySelector("table")) continue;
-
-            const match = cell.textContent.match(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/);
-            if (!match) continue;
-
-            const pad = (value) => String(value).padStart(2, "0");
-            slots.push({
-                number: number,
-                startTime: `${pad(match[1])}:${match[2]}`,
-                endTime: `${pad(match[3])}:${match[4]}`
+function shiftDate(dateStr, days) {
+    const date = new Date(`${dateStr}T00:00:00`);
+    date.setDate(date.getDate() + days);
+    return formatDate(date);
+}
+
+/**
+ * 构造组合作息方案(comboSchedule)。
+ *
+ * 规则按日期区间匹配:夏令时 4/7–10/6,冬令时 10/7–次年 4/6。
+ * 两者首尾相接,全年任意一天都能命中一条,不存在空档。
+ *
+ * 一个学期可能横跨两套令时区间,也可能跨年:
+ *   秋季学期(9 月开学、次年 1 月结束):当年夏令时尾巴 + 当年冬令时;
+ *   春季学期(2 月开学、7 月结束):上一年冬令时尾巴 + 当年夏令时。
+ * 所以这里不假设"学期属于某一年",而是把学期覆盖到的每一年都生成一对区间,
+ * 再逐个与学期区间求交集,命中的才写进方案——这样任何学期都能被完整覆盖。
+ *
+ * 拿到开学日期时,方案只覆盖本学期,避免与其它学期的作息方案互相干扰;
+ * 拿不到开学日期时退回全年固定的两个区间(以导入当天的"夏令时年份"为基准)。
+ *
+ * @param {string|null} semesterStartDate 本学期开学日期 "YYYY-MM-DD",取不到传 null
+ * @param {number} totalWeeks 本学期总周数
+ */
+function buildComboSchedule(semesterStartDate, totalWeeks) {
+    // 本学期日期区间(含最后一周的最后一天)
+    const semesterEnd = semesterStartDate
+        ? shiftDate(semesterStartDate, Math.max(1, totalWeeks) * 7 - 1)
+        : null;
+
+    /**
+     * 求「本学期」与「某个令时区间」的交集,无交集返回 null。
+     */
+    const intersect = (seasonStart, seasonEnd) => {
+        if (!semesterStartDate || !semesterEnd) return null;
+        const start = semesterStartDate > seasonStart ? semesterStartDate : seasonStart;
+        const end = semesterEnd < seasonEnd ? semesterEnd : seasonEnd;
+        return start <= end ? { startDate: start, endDate: end } : null;
+    };
+
+    // 学期覆盖到的年份范围:往前多取一年,兜住"1 月结束的秋季学期"和"2 月开学的春季学期"
+    const now = new Date();
+    const month = now.getMonth() + 1;
+    const fallbackSummerYear = (month >= 8 || month < 4) ? now.getFullYear() : now.getFullYear() - 1;
+    const firstYear = semesterStartDate
+        ? Number(semesterStartDate.slice(0, 4))
+        : fallbackSummerYear;
+    const lastYear = semesterEnd ? Number(semesterEnd.slice(0, 4)) : firstYear;
+
+    const publicSchedules = [];
+
+    for (let year = firstYear - 1; year <= lastYear; year++) {
+        // 该年度的夏令时:year/4/7 – year/10/6;冬令时:year/10/7 – (year+1)/4/6
+        const summerRange = intersect(`${year}-04-07`, `${year}-10-06`);
+        if (summerRange) {
+            publicSchedules.push({
+                name: "夏令时",
+                startDate: summerRange.startDate,
+                endDate: summerRange.endDate,
+                defaultClassDuration: 45,
+                defaultBreakDuration: 10,
+                timeSlots: SummerTimeSlots
+            });
+        }
+
+        const winterRange = intersect(`${year}-10-07`, `${year + 1}-04-06`);
+        if (winterRange) {
+            publicSchedules.push({
+                name: "冬令时",
+                startDate: winterRange.startDate,
+                endDate: winterRange.endDate,
+                defaultClassDuration: 45,
+                defaultBreakDuration: 10,
+                timeSlots: WinterTimeSlots
             });
-            number += 1;
         }
     }
 
-    return slots;
+    // 拿不到开学日期,或学期与所有令时区间都没有交集(例如纯夏季小学期)时,退回全年固定区间
+    if (publicSchedules.length === 0) {
+        publicSchedules.push({
+            name: "夏令时",
+            startDate: `${fallbackSummerYear}-04-07`,
+            endDate: `${fallbackSummerYear}-10-06`,
+            defaultClassDuration: 45,
+            defaultBreakDuration: 10,
+            timeSlots: SummerTimeSlots
+        });
+        publicSchedules.push({
+            name: "冬令时",
+            startDate: `${fallbackSummerYear}-10-07`,
+            endDate: `${fallbackSummerYear + 1}-04-06`,
+            defaultClassDuration: 45,
+            defaultBreakDuration: 10,
+            timeSlots: WinterTimeSlots
+        });
+    }
+
+    // 按开始日期排序,保证规则顺序与时间顺序一致(应用取第一条命中的规则)
+    publicSchedules.sort((a, b) => a.startDate.localeCompare(b.startDate));
+
+    return {
+        name: COMBO_SCHEDULE_NAME,
+        publicSchedules: publicSchedules
+    };
 }
 
 /**
@@ -721,18 +868,43 @@ async function saveCourseConfigIfPossible(config) {
     }
 }
 
+/**
+ * 提交基础时间段(组合作息的骨架)。
+ * 必须成功,否则后面的 saveComboSchedule 会被应用直接拒绝(顺序强校验)。
+ */
 async function importPresetTimeSlots(timeSlots) {
     if (timeSlots.length === 0) {
         window.shiguangBridge.showToast("警告:时间段为空,未导入时间段信息。");
-        return;
+        return false;
     }
 
     window.shiguangBridge.showToast(`正在导入 ${timeSlots.length} 个预设时间段...`);
     try {
         await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
+        return true;
     } catch (error) {
         window.shiguangBridge.showToast("导入时间段失败: " + error.message);
         console.error("JS: Save Time Slots Error:", error);
+        return false;
+    }
+}
+
+/**
+ * 提交组合作息方案:夏令时 / 冬令时按日期区间自动切换。
+ * 应用会以 importPresetTimeSlots 提交的基础时间段为骨架做差量合并,
+ * 本方案每个模板都提交完整 1-11 节,所以合并结果与模板本身一致。
+ * 调用后本次导入任务不再允许调用 savePresetTimeSlots,因此顺序必须固定为「先时间段、后方案」。
+ */
+async function importComboSchedule(comboSchedule) {
+    window.shiguangBridge.showToast("正在导入夏令时/冬令时组合作息方案...");
+    try {
+        await window.shiguangBridgePromise.saveComboSchedule(JSON.stringify(comboSchedule));
+        console.log("JS: 组合作息方案已导入:", comboSchedule);
+        return true;
+    } catch (error) {
+        window.shiguangBridge.showToast("导入作息方案失败: " + error.message);
+        console.error("JS: Save Combo Schedule Error:", error);
+        return false;
     }
 }
 
@@ -809,8 +981,7 @@ async function runImportFlow() {
         return;
     }
 
-    const timeSlots = parseTimeSlots(timetableDoc);
-    console.log(`JS: 解析到 ${courses.length} 门课程,${timeSlots.length} 个时间段。`);
+    console.log(`JS: 解析到 ${courses.length} 门课程。`);
 
     // 学号取自课表页隐藏域,用来查「我的课表」接口
     const studentId = (timetableDoc.querySelector("#MainWork_WUCpyjhdy_HFfilename") || {}).value || "";
@@ -827,6 +998,11 @@ async function runImportFlow() {
         }
         : null;
 
+    // 基础时间段(骨架)用导入当下的令时那一套;方案里两个模板都会提交完整 11 节,
+    // 骨架实际只是用来满足「先时间段、后方案」的顺序要求,不会影响最终显示。
+    const baseTimeSlots = isSummerTimeNow() ? SummerTimeSlots : WinterTimeSlots;
+    const comboSchedule = buildComboSchedule(semesterStartDate, totalWeeks);
+
     const saveResult = await saveCourses(courses);
     if (!saveResult) {
         console.log("JS: 课程保存失败,流程终止。");
@@ -834,16 +1010,29 @@ async function runImportFlow() {
     }
 
     await saveCourseConfigIfPossible(config);
-    await importPresetTimeSlots(timeSlots);
+
+    // 顺序不可调换:saveComboSchedule 依赖 savePresetTimeSlots 成功写入的基础时间段。
+    const timeSlotSaved = await importPresetTimeSlots(baseTimeSlots);
+    if (timeSlotSaved) {
+        await importComboSchedule(comboSchedule);
+    } else {
+        window.shiguangBridge.showToast("时间段导入失败,已跳过组合作息方案。");
+        console.log("JS: 基础时间段未写入成功,跳过 saveComboSchedule。");
+    }
+
+    const scheduleBrief = comboSchedule.publicSchedules
+        .map((rule) => `${rule.name} ${rule.startDate} ~ ${rule.endDate}`)
+        .join("\n");
 
     await window.shiguangBridgePromise.showAlert(
         "导入完成",
         `已导入 ${semesterText} 共 ${courses.length} 门课程。\n\n` +
-        `作息时间取自研究生系统课表页当前显示的作息(全天 ${timeSlots.length} 节),` +
-        "学校切换夏令时/冬令时后请重新导入,或在应用内手动修改时间段。\n\n" +
+        `作息已写入组合作息方案(全天 ${baseTimeSlots.length} 节),将于每年 4 月 7 日切换到夏令时、` +
+        "10 月 7 日切换到冬令时,无需重新导入。\n\n" +
+        `本次方案的生效区间:\n${scheduleBrief}\n\n` +
         (semesterStartDate
             ? `开学日期由课表数据反推得到:${semesterStartDate},请在应用内核对。`
-            : "未能反推开学日期,请在应用内手动设置。"),
+            : "未能反推开学日期(作息方案已退回全年固定区间),请在应用内手动设置开学日期。"),
         "我知道了"
     );