Explorar el Código

Merge pull request #826 from ShiGuangSchedule/pending

星河欲转 hace 3 semanas
padre
commit
290e6605c9

+ 15 - 0
index/root_index.yaml

@@ -1217,3 +1217,18 @@ schools:
     name: "燕山大学"
     initial: "Y"
     resource_folder: "YSU"
+
+  - id: "XHKJZYXY"
+    name: "宣化科技职业学院"
+    initial: "X"
+    resource_folder: "XHKJZYXY"
+
+  - id: "HEBAU"
+    name: "河北农业大学"
+    initial: "H"
+    resource_folder: "HEBAU"
+    
+  - id: "BNUZH"
+    name: "北京师范大学珠海校区"
+    initial: "B"
+    resource_folder: "BNUZH"    

+ 9 - 0
resources/BNUZH/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/BNUZH/adapters.yaml
+adapters:
+  - adapter_id: "BNUZH_01"
+    adapter_name: "北京师范大学珠海校区金智教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "school.js"
+    import_url: "https://jwxt.bnuzh.edu.cn/caslogin"
+    maintainer: "sbVAN"
+    description: "北京师范大学珠海校区教务(金智/青果KINGOSOFT)适配,网络请求直取课表数据与作息时间,自动获取开学日期(可确认修改),不依赖课表页面。登录教务系统后直接点击执行导入即可,无需切换到课表页面"

+ 445 - 0
resources/BNUZH/school.js

@@ -0,0 +1,445 @@
+// 北京师范大学珠海校区(金智/青果 KINGOSOFT 教务)拾光课程表适配脚本
+//
+// 方案参考:wiki《常见教务系统适配案例参考》青果教务案例(resources/WZZY),
+// 全部数据通过带会话的网络请求获取,不依赖课表页面 DOM,支持开学日期导入。
+//
+// 本校实测接口:
+//   学期列表    POST /frame/droplist/getDropLists.action
+//               body: comboBoxName=Ms_KBBP_FBXKJGXNXQ&...(参数见请求体)
+//               → JSON [{"code":"2026,0","name":"2026-2027学年秋季学期"},...]
+//               注:本校 code 以逗号分隔(案例为 "-"),解析时已区分
+//   课表数据    GET  /wsxk/xkjg.ckdgxsxdkchj_data10319.jsp?params=<base64(xn=..&xq=..)>
+//               → GBK 编码 HTML 列表表格(网络数据源,非课表页面)
+//               注:案例使用 student/wsxk.xskcb10319.jsp(周×节矩阵视图),
+//                   本校同族接口的列表视图字段更稳定,故用本接口,数据同源
+//   开学日期    POST /frame/desk/showLessonScheduleInfosV14.action?xn=..&xq=..&jxz=1
+//               → 第一周课表 HTML,表头形如 <label>一</label><br>09-07,
+//                 正则与青果案例一致;年份按学期推算(春季学期为 xn+1)
+//   作息时间    GET  /public/SchoolTimetable.show.jsp → 制表符分隔节次表
+//               (案例为写死作息,本校教务提供公布接口,优先动态获取)
+//   当前学期    POST /jw/common/showYearTerm.action(getDropLists 失败时的回退默认值)
+//
+// 学年学期编码:xn=学年前年份(2026 即 2026-2027 学年),xq=0 秋季 / 1 春季
+
+const HOST = 'https://jwxt.bnuzh.edu.cn';
+const DROPLIST_URL = HOST + '/frame/droplist/getDropLists.action';
+const YEARTERM_URL = HOST + '/jw/common/showYearTerm.action';
+const KB_DATA_URL = HOST + '/wsxk/xkjg.ckdgxsxdkchj_data10319.jsp';
+const LESSON_INFO_URL = HOST + '/frame/desk/showLessonScheduleInfosV14.action';
+const TIMETABLE_URL = HOST + '/public/SchoolTimetable.show.jsp';
+
+const DROPLIST_BODY =
+    'comboBoxName=Ms_KBBP_FBXKJGXNXQ&paramValue=&isYXB=0&isCDDW=0&isPYCC=0' +
+    '&isPYCCNJ=0&zyPyccFiled=&isKBLB=0&isXQ=0&isBJ=0&isSXJD=0&isDJKSLB=0';
+
+const DAY_MAP = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 };
+
+// 教务未公布作息时的兜底(2026-2027 学年秋季学期)
+const FALLBACK_TIME_SLOTS = [
+    { number: 1, startTime: '08:00', endTime: '08:45' },
+    { number: 2, startTime: '08:55', endTime: '09:40' },
+    { number: 3, startTime: '10:00', endTime: '10:45' },
+    { number: 4, startTime: '10:55', endTime: '11:40' },
+    { 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' },
+    { number: 9, startTime: '18:00', endTime: '18:45' },
+    { number: 10, startTime: '18:55', endTime: '19:40' },
+    { number: 11, startTime: '19:50', endTime: '20:35' },
+    { number: 12, startTime: '20:45', endTime: '21:30' }
+];
+
+function validateYearInput(input) {
+    if (/^\d{4}$/.test(String(input).trim())) return false;
+    return '请输入四位数字学年,如 2026';
+}
+
+function validateDateInput(input) {
+    if (/^\d{4}-\d{2}-\d{2}$/.test(String(input).trim())) return false;
+    return '请输入正确格式的日期,如 2026-09-07';
+}
+
+async function fetchGbk(url, options) {
+    const resp = await fetch(url, options);
+    return new TextDecoder('gbk').decode(await resp.arrayBuffer());
+}
+
+// ---------- 学期 ----------
+
+// 案例同款:网络请求获取学期列表(含默认值逻辑,与课表页面解耦)
+async function fetchSemesterList() {
+    try {
+        const resp = await fetch(DROPLIST_URL, {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
+                'X-Requested-With': 'XMLHttpRequest'
+            },
+            body: DROPLIST_BODY,
+            credentials: 'include'
+        });
+        const list = await resp.json();
+        if (Array.isArray(list) && list.length > 0 && list[0].code) return list;
+    } catch (e) { /* 回退到默认值方案 */ }
+    return null;
+}
+
+// getDropLists 不可用时:以教务返回的当前学期为默认值生成候选列表
+function buildSemesterList(current) {
+    const list = [];
+    let xn = parseInt(current.xn, 10);
+    let xq = current.xq === '1' ? 1 : 0;
+    for (let i = 0; i < 8; i++) {
+        list.push({
+            code: xn + ',' + xq,
+            name: xn + '-' + (xn + 1) + '学年' + (xq === 0 ? '秋季' : '春季') + '学期'
+        });
+        if (xq === 0) { xn -= 1; xq = 1; } else { xq = 0; }
+    }
+    return list;
+}
+
+async function selectSemester() {
+    // 当前学期作为默认选中值(案例 dqxnxq.do 的默认值逻辑,本校由 showYearTerm 提供)
+    let current = null;
+    try {
+        const resp = await fetch(YEARTERM_URL, { method: 'POST' });
+        const data = JSON.parse(await resp.text());
+        if (data && data.xn) current = { xn: data.xn, xq: String(data.xqM) };
+    } catch (e) { /* 列表仍可用时不影响主流程 */ }
+
+    let list = await fetchSemesterList();
+    if (!list && current) list = buildSemesterList(current);
+
+    if (!list) {
+        // 列表与当前学期均不可得时,手动输入
+        const year = await window.shiguangBridgePromise.showPrompt(
+            '选择学年', '请输入学年的前年份,如 2026 表示 2026-2027 学年:', '2026', 'validateYearInput'
+        );
+        if (year === null) return null;
+        const xq = await window.shiguangBridgePromise.showSingleSelection(
+            '选择学期', JSON.stringify(['秋季学期', '春季学期']), 0
+        );
+        if (xq === null) return null;
+        return { xn: String(year).trim(), xq: String(xq) };
+    }
+
+    let defaultIndex = 0;
+    if (current) {
+        const idx = list.findIndex(
+            item => String(item.code).replace(/\s/g, '') === current.xn + ',' + current.xq
+        );
+        if (idx >= 0) defaultIndex = idx;
+    }
+
+    const names = list.slice(0, 10).map(item => item.name);
+    const index = await window.shiguangBridgePromise.showSingleSelection(
+        '选择导入学期', JSON.stringify(names), defaultIndex
+    );
+    if (index === null || index < 0 || index >= list.length) return null;
+    // 本校 code 形如 "2026,0"(逗号),案例为 "2026-0",两种分隔符都兼容
+    const parts = String(list[index].code).split(/[,\-]/);
+    const xn = parts[0].trim();
+    const xq = (parts[1] || '0').trim();
+    if (!/^\d{4}$/.test(xn)) return null;
+    return { xn, xq };
+}
+
+// ---------- 课表数据 ----------
+
+async function fetchCourseHtml(xn, xq) {
+    const params = btoa('xn=' + xn + '&xq=' + xq);
+    const html = await fetchGbk(KB_DATA_URL + '?params=' + params, { credentials: 'include' });
+    if (!html || html.includes('凭证已失效')) return null;
+    return html;
+}
+
+function expandWeeks(start, end, parity) {
+    const weeks = [];
+    for (let w = start; w <= end; w++) {
+        if (parity === '单' && w % 2 === 0) continue;
+        if (parity === '双' && w % 2 === 1) continue;
+        weeks.push(w);
+    }
+    return weeks;
+}
+
+// 单个时间段,如 "1-16周 三[7-8] 丽泽楼C202(140)"
+function parseTimeBlock(block) {
+    const m = block.match(
+        /^(\d+)\s*[-—~-]\s*(\d+)周(?:\((单|双)\))?\s*([一二三四五六日天])\s*\[\s*(\d{1,2})(?:\s*[-—~-]\s*(\d{1,2}))?\s*\]\s*(.+)$/
+    );
+    if (!m) return null;
+    const day = DAY_MAP[m[4]];
+    if (!day) return null;
+    const startSection = parseInt(m[5], 10);
+    const endSection = m[6] ? parseInt(m[6], 10) : startSection;
+    const weeks = expandWeeks(parseInt(m[1], 10), parseInt(m[2], 10), m[3]);
+    if (weeks.length === 0 || endSection < startSection) return null;
+    // 去掉地点末尾的教室容量,如 "丽泽楼C202(140)" → "丽泽楼C202"
+    const position = m[7].trim().replace(/\(\d+\)$/, '');
+    return { day, startSection, endSection, weeks, position };
+}
+
+function parseCourses(html) {
+    const courses = [];
+    const doc = new DOMParser().parseFromString(html, 'text/html');
+    const rows = doc.querySelectorAll('tbody tr');
+    for (const row of rows) {
+        const cells = row.querySelectorAll('td');
+        if (cells.length < 6) continue;
+        const nameMatch = cells[0].textContent.trim().match(/^\[[^\]]+\]\s*(.+)$/);
+        if (!nameMatch) continue;
+        const name = nameMatch[1].trim();
+        const teacher = cells[4].textContent.trim().replace(/;/g, '、');
+        const timeText = cells[5].textContent.trim();
+        if (!timeText) continue;
+
+        for (const block of timeText.split(',')) {
+            const parsed = parseTimeBlock(block.trim());
+            if (!parsed) continue;
+            const course = {
+                name: name,
+                teacher: teacher,
+                position: parsed.position,
+                day: parsed.day,
+                startSection: parsed.startSection,
+                endSection: parsed.endSection,
+                weeks: parsed.weeks
+            };
+            courses.push(course);
+        }
+    }
+    return courses;
+}
+
+// 节次与周次两阶段合并去重(与青果案例 mergeAndDistinctCourses 同逻辑):
+// 阶段 1 合并同课程同周次的连续节次、去除完全重复记录;
+// 阶段 2 合并同课程同节次的周次(如 1-8 周 + 9-16 周 → 1-16 周)
+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) ? [...new Set(c.weeks)].sort((a, b) => a - b) : []
+    }));
+
+    const byKey = (a, b) =>
+        a.name.localeCompare(b.name) ||
+        a.teacher.localeCompare(b.teacher) ||
+        a.position.localeCompare(b.position) ||
+        (a.day || 0) - (b.day || 0);
+
+    list.sort((a, b) =>
+        byKey(a, b) ||
+        a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
+        (a.startSection || 0) - (b.startSection || 0));
+
+    const step1 = [];
+    let current = list[0];
+    for (let i = 1; i < list.length; i++) {
+        const next = list[i];
+        const sameGroup =
+            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 (sameGroup && isContinuous) {
+            current.endSection = next.endSection;
+        } else if (sameGroup && isDuplicate) {
+            continue;
+        } else {
+            step1.push(current);
+            current = next;
+        }
+    }
+    step1.push(current);
+
+    step1.sort((a, b) =>
+        byKey(a, b) ||
+        (a.startSection || 0) - (b.startSection || 0) ||
+        (a.endSection || 0) - (b.endSection || 0));
+
+    const step2 = [];
+    let cur = step1[0];
+    for (let i = 1; i < step1.length; i++) {
+        const nxt = step1[i];
+        const sameGroup =
+            cur.name === nxt.name &&
+            cur.teacher === nxt.teacher &&
+            cur.position === nxt.position &&
+            cur.day === nxt.day &&
+            cur.startSection === nxt.startSection &&
+            cur.endSection === nxt.endSection;
+        if (sameGroup) {
+            cur.weeks = [...new Set([...cur.weeks, ...nxt.weeks])].sort((a, b) => a - b);
+        } else {
+            step2.push(cur);
+            cur = nxt;
+        }
+    }
+    step2.push(cur);
+    return step2;
+}
+
+// ---------- 开学日期 ----------
+
+// 取第一周课表表头的 MM-DD(形如 <label>一</label><br>09-07),推算完整日期
+async function fetchSemesterStartDate(xn, xq) {
+    try {
+        const html = await fetchGbk(
+            LESSON_INFO_URL + '?xn=' + xn + '&xq=' + xq + '&jxz=1',
+            {
+                method: 'POST',
+                headers: { 'X-Requested-With': 'XMLHttpRequest' },
+                credentials: 'include'
+            }
+        );
+        const match = html.match(/<br\s*\/?>\s*(\d{2})-(\d{2})/);
+        if (match) {
+            const year = xq === '1' ? String(parseInt(xn, 10) + 1) : xn;
+            return year + '-' + match[1] + '-' + match[2];
+        }
+    } catch (e) { /* 返回 null 由用户手动填写 */ }
+    return null;
+}
+
+// ---------- 作息时间 ----------
+
+async function fetchTimeSlots() {
+    try {
+        const text = await fetchGbk(TIMETABLE_URL, { credentials: 'include' });
+        const slots = [];
+        const re = /(\d{1,2})\t(\d{2}:\d{2})\t(\d{2}:\d{2})/g;
+        let m;
+        while ((m = re.exec(text)) !== null) {
+            const number = parseInt(m[1], 10);
+            if (number >= 1) {
+                slots.push({ number: number, startTime: m[2], endTime: m[3] });
+            }
+        }
+        slots.sort((a, b) => a.number - b.number);
+        if (slots.length > 0 && slots[0].number === 1) return slots;
+    } catch (e) { /* 回退内置 */ }
+    return FALLBACK_TIME_SLOTS;
+}
+
+// ---------- 导入流程 ----------
+
+async function runImportFlow() {
+    // 脚本必须在教务站点域内执行,登录会话才会随请求携带
+    if (!window.location.hostname.endsWith('bnuzh.edu.cn')) {
+        await window.shiguangBridgePromise.showAlert(
+            '无法导入',
+            '当前不在教务系统页面内,请先完成教务系统登录再执行导入。',
+            '我知道了'
+        );
+        return;
+    }
+
+    const confirmed = await window.shiguangBridgePromise.showAlert(
+        '导入课表',
+        '即将从教务系统获取课表、作息与开学日期。请确保当前页面已登录教务系统。',
+        '开始导入'
+    );
+    if (!confirmed) {
+        window.shiguangBridge.showToast('已取消导入。');
+        return;
+    }
+
+    // 1. 选择学期(列表来自网络请求,默认选中最新学期)
+    const semester = await selectSemester();
+    if (!semester) {
+        window.shiguangBridge.showToast('已取消导入。');
+        return;
+    }
+    const { xn, xq } = semester;
+
+    // 2. 网络请求获取课表数据
+    window.shiguangBridge.showToast('正在获取课表数据...');
+    let html = null;
+    try {
+        html = await fetchCourseHtml(xn, xq);
+    } catch (e) {
+        html = null;
+    }
+    if (!html) {
+        await window.shiguangBridgePromise.showAlert(
+            '获取失败',
+            '未能获取课表数据,可能尚未登录或会话已过期,请确认登录后重试。',
+            '确定'
+        );
+        return;
+    }
+    const courses = mergeAndDistinctCourses(parseCourses(html));
+    if (courses.length === 0) {
+        await window.shiguangBridgePromise.showAlert(
+            '没有课程',
+            '已连接教务系统,但该学期没有解析到课程记录。',
+            '确定'
+        );
+        return;
+    }
+
+    // 3. 开学日期(教务第一周课表)并让用户确认
+    window.shiguangBridge.showToast('正在获取开学日期...');
+    const startDate = await fetchSemesterStartDate(xn, xq);
+    const confirmedDate = await window.shiguangBridgePromise.showPrompt(
+        '确认开学日期',
+        '请确认本学期第一周周一的日期(格式 YYYY-MM-DD):',
+        startDate || '',
+        'validateDateInput'
+    );
+    if (confirmedDate === null) {
+        window.shiguangBridge.showToast('导入已取消。');
+        return;
+    }
+
+    // 4. 总周数按课程周次最大值推算
+    let maxWeek = 0;
+    for (const c of courses) {
+        for (const w of c.weeks) if (w > maxWeek) maxWeek = w;
+    }
+    try {
+        await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
+            semesterStartDate: String(confirmedDate).trim(),
+            semesterTotalWeeks: maxWeek > 0 ? maxWeek : 20,
+            defaultClassDuration: 45,
+            defaultBreakDuration: 10
+        }));
+    } catch (e) {
+        window.shiguangBridge.showToast('课表配置保存失败:' + e.message);
+    }
+
+    // 5. 作息时间(教务公布优先,失败回退内置)
+    const timeSlots = await fetchTimeSlots();
+    try {
+        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
+    } catch (e) {
+        window.shiguangBridge.showToast('作息时间保存失败:' + e.message);
+    }
+
+    // 6. 提交课程
+    try {
+        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+    } catch (e) {
+        await window.shiguangBridgePromise.showAlert('导入失败', '课程数据保存失败:' + e.message, '确定');
+        return;
+    }
+
+    await window.shiguangBridgePromise.showAlert(
+        '导入成功',
+        '已导入 ' + courses.length + ' 条课程记录、作息时间与开学日期 ' + String(confirmedDate).trim() + '。',
+        '确定'
+    );
+    window.shiguangBridge.notifyTaskCompletion();
+}
+
+runImportFlow();

+ 88 - 29
resources/CMC/cmc_01.js

@@ -1,9 +1,11 @@
 // 成都医学院教务(乘方教务)适配器
-// 流程:选一次学期(课表与考试共用)→ 导入课表 → 询问是否导入考试 → 合并保存
+// 流程:选一次学期(课表/考试/上课任务/开学日期共用)→ 拉上课任务与课表 → 询问是否导入考试 → 保存配置、课程与作息
 // 接口:
-//   GET  /new/student/xsgrkb/week.page            课表页(学期下拉 + 作息表)
+//   GET  /new/student/xsgrkb/week.page             课表页(学期下拉 + 作息表)
 //   POST /new/student/xsgrkb/getCalendarWeekDatas  整学期课程数据
 //   POST /new/student/xsksrw/paginateXsksrw        学生考试任务
+//   POST /new/student/xskcrw/skrwDatas             上课任务(学分与选修类别)
+//   POST /new/xlxx/getDatesOfWeek                  第1周日期(开学日期)
 
 // 周次字符串
 function parseWeeks(weekStr) {
@@ -12,14 +14,13 @@ function parseWeeks(weekStr) {
     return [...new Set(weeks)].sort((a, b) => a - b);
 }
 
-// 解析按周场地字符串
+// 按周场地字符串
 function parseVenueWeeks(jxcdmc2) {
     const venueMap = new Map();
     String(jxcdmc2 || "").split(",").forEach(part => {
         const match = part.trim().match(/^(.*?)-(\d+)$/);
         if (!match) return;
         const week = parseInt(match[2], 10);
-        if (isNaN(week)) return;
         const key = match[1].trim() || "不用场地";
         if (!venueMap.has(key)) venueMap.set(key, []);
         venueMap.get(key).push(week);
@@ -35,12 +36,13 @@ function resolvePosition(item) {
     return "待定";
 }
 
+// 教师名
 function cleanTeacherName(raw) {
     return [...new Set(String(raw || "").replace(/\[[^\]]*\]/g, "").split(",").map(n => n.trim()).filter(Boolean))].join(",");
 }
 
 // 课表接口数据
-function parseCourseList(apiJson, slotMap) {
+function parseCourseList(apiJson, slotMap, taskMap) {
     if (!apiJson) throw new Error("课表接口无响应");
     if (apiJson.code !== 0) {
         const message = String(apiJson.message || "").trim();
@@ -57,27 +59,30 @@ function parseCourseList(apiJson, slotMap) {
         if (!item.kcmc || !allWeeks.length || isNaN(day) || isNaN(startSection) || isNaN(endSection) ||
             day < 1 || day > 7 || startSection > endSection) return;
 
-        const teacher = cleanTeacherName(item.teaxms || item.pkr) || "未知";
+        const teacher = cleanTeacherName(item.teaxms) || "未知";
+        const info = taskMap.get(item.kcrwdm);
+        const name = `${item.kcmc.trim()}${info && info.tag ? `(${info.tag})` : ""}`;
+        const actualStart = String(item.qssj || "").slice(0, 5);
+        const actualEnd = String(item.jssj || "").slice(0, 5);
+        const expectedStart = slotMap[startSection] && slotMap[startSection].start;
+        const expectedEnd = slotMap[endSection] && slotMap[endSection].end;
+        const isCustomTime = actualStart && actualEnd && (actualStart !== expectedStart || actualEnd !== expectedEnd);
         const venues = parseVenueWeeks(item.jxcdmc2);
         const venueEntries = venues.size > 0
             ? Array.from(venues.entries(), ([position, weeks]) => ({ position, weeks: [...new Set(weeks)].sort((a, b) => a - b) }))
             : [{ position: resolvePosition(item), weeks: allWeeks }];
 
         venueEntries.forEach(({ position, weeks }) => {
-            const course = { name: item.kcmc.trim(), teacher, position, day, startSection, endSection, weeks };
-
-            const actualStart = String(item.qssj || "").slice(0, 5);
-            const actualEnd = String(item.jssj || "").slice(0, 5);
-            const expectedStart = slotMap[startSection] && slotMap[startSection].start;
-            const expectedEnd = slotMap[endSection] && slotMap[endSection].end;
-            if (actualStart && actualEnd && (actualStart !== expectedStart || actualEnd !== expectedEnd)) {
+            const course = { name, teacher, position, day, startSection, endSection, weeks };
+            if (info) course.credit = info.credit;
+            if (isCustomTime) {
                 course.isCustomTime = true;
                 course.customStartTime = actualStart;
                 course.customEndTime = actualEnd;
             }
-            
+
             const key = [course.name, teacher, position, day,
-                course.isCustomTime ? actualStart + actualEnd : `${startSection}-${endSection}`].join("__");
+                isCustomTime ? `${actualStart}${actualEnd}` : `${startSection}-${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);
@@ -120,6 +125,22 @@ function parseExamList(rows, slots) {
     return exams;
 }
 
+// 任选课类别标识
+function resolveElectiveTag(task) {
+    if (String(task.xdfsmc || "").trim() !== "任选") return "";
+    if (String(task.kcflmc || "").includes("艺术")) return "艺术";
+    if (String(task.kcdlmc || "").includes("通识")) return "通识";
+    if (String(task.kcdlmc || "").includes("专业")) return "专业";
+    return "";
+}
+
+// 上课任务数据
+function parseTaskList(rows) {
+    const map = new Map();
+    rows.forEach(item => map.set(item.kcrwdm, { credit: item.xf, tag: resolveElectiveTag(item) }));
+    return map;
+}
+
 // 从 week.page 源码提取作息表
 function parseBusinessHoursFromHtml(htmlText) {
     const match = htmlText.match(/var\s+businessHours\s*=\s*\$\.parseJSON\('(\[.*?\])'\);/);
@@ -196,9 +217,7 @@ async function selectSemester(semesterOptions) {
 
 // 询问是否同时导入考试
 async function askImportExams() {
-    const bridge = window.shiguangBridgePromise;
-    if (!bridge || typeof bridge.showAlert !== "function") return true;
-    return await bridge.showAlert(
+    return await window.shiguangBridgePromise.showAlert(
         "导入考试安排",
         "是否同时导入本学期的考试安排?\n(期中/期末/补考将显示在课表对应日期)",
         "确定导入"
@@ -212,7 +231,7 @@ async function fetchSchedulePage() {
     return response.text();
 }
 
-// 乘方统一表单 POST(课表/考试共用),附带 JSON 请求头与会话
+// 乘方统一表单 POST(课表/考试/任务/开学日期共用),附带 JSON 请求头与会话
 async function postForm(url, formData) {
     const response = await fetch(url, {
         method: "POST",
@@ -238,21 +257,28 @@ async function fetchCourseData(xnxqdm) {
     return (await postForm("/new/student/xsgrkb/getCalendarWeekDatas", formData)).json();
 }
 
-// 分页拉取指定学期的全部考试任务
-async function fetchExamData(xnxqdm) {
+// 请求第 1 周日期(开学日期)
+async function fetchSemesterStartDate(xnxqdm) {
+    const formData = new URLSearchParams();
+    formData.append("xnxqdm", xnxqdm);
+    formData.append("zc", "1");
+    const rows = await (await postForm("/new/xlxx/getDatesOfWeek", formData)).json();
+    return rows.find(item => item.xqmc === "1").rq;
+}
+
+// 分页拉取 rows/total 数据(考试/任务共用)
+async function fetchPagedRows(url, baseParams, sort) {
     const allRows = [];
-    const pageSize = 100;
     let page = 1;
 
     for (;;) {
-        const formData = new URLSearchParams();
-        formData.append("xnxqdm", xnxqdm);
+        const formData = new URLSearchParams(baseParams);
         formData.append("page", String(page));
-        formData.append("rows", String(pageSize));
-        formData.append("sort", "zc,xq,jcdm2");
+        formData.append("rows", "100");
+        formData.append("sort", sort);
         formData.append("order", "asc");
 
-        const json = await (await postForm("/new/student/xsksrw/paginateXsksrw", formData)).json();
+        const json = await (await postForm(url, formData)).json();
         const rows = Array.isArray(json.rows) ? json.rows : [];
         allRows.push(...rows);
 
@@ -263,16 +289,37 @@ async function fetchExamData(xnxqdm) {
     return allRows;
 }
 
+// 分页拉取指定学期的全部考试任务
+async function fetchExamData(xnxqdm) {
+    return fetchPagedRows("/new/student/xsksrw/paginateXsksrw", { xnxqdm, ksaplxdm: "", kslbdm: "" }, "zc,xq,jcdm2");
+}
+
+// 分页拉取指定学期的全部上课任务
+async function fetchTaskData(xnxqdm) {
+    return fetchPagedRows("/new/student/xskcrw/skrwDatas", { xnxqdm, kcdldm: "", kcfldm: "", kcmc: "" }, "kcrwdm,xdfsdm");
+}
+
+// 保存课表配置(学期 20 周、单节 40 分钟)
+async function saveConfig(semesterStartDate) {
+    await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
+        semesterStartDate,
+        semesterTotalWeeks: 20,
+        defaultClassDuration: 40
+    }));
+}
+
+// 保存课程
 async function saveCourses(courses) {
     await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
 }
 
+// 保存作息时间
 async function saveTimeSlots(timeSlots) {
     if (timeSlots.length === 0) return;
     await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
 }
 
-// 编排导入流程:提示 → 选学期 → 请求课表与考试 → 合并保存课程与作息时间
+// 编排导入流程:提示 → 选学期 → 拉任务与课表 → 询问考试 → 保存配置、课程与作息
 async function runImportFlow() {
     try {
         const confirmed = await promptUserToStart();
@@ -287,7 +334,13 @@ async function runImportFlow() {
 
         const { slots, map: slotMap } = parseBusinessHoursFromHtml(pageHtml);
         window.shiguangBridge.showToast(`正在获取 ${semester.label} 的课表...`);
-        const courses = parseCourseList(await fetchCourseData(semester.value), slotMap);
+        let taskMap = new Map();
+        try {
+            taskMap = parseTaskList(await fetchTaskData(semester.value));
+        } catch (error) {
+            window.shiguangBridge.showToast(`上课任务获取失败,学分与类别标识已跳过:${error.message}`);
+        }
+        const courses = parseCourseList(await fetchCourseData(semester.value), slotMap, taskMap);
 
         if (courses.length === 0) {
             await window.shiguangBridgePromise.showAlert(
@@ -311,6 +364,12 @@ async function runImportFlow() {
             if (exams.length === 0) window.shiguangBridge.showToast("该学期暂时没有考试安排");
         }
 
+        try {
+            await saveConfig(await fetchSemesterStartDate(semester.value));
+        } catch (error) {
+            window.shiguangBridge.showToast(`课表配置导入失败,已跳过:${error.message}`);
+        }
+
         await saveCourses([...courses, ...exams]);
         try {
             await saveTimeSlots(timeSlots);

+ 9 - 0
resources/HEBAU/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/HEBAU/adapters.yaml
+adapters:
+  - adapter_id: "HEBAU"
+    adapter_name: "河北农业大学教务系统"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "hebau.js"
+    import_url: "http://urp.hebau.edu.cn:1009/jwapp/sys/homeapp/home/index.html#/"
+    maintainer: "Haooz"
+    description: "河北农业大学课表导入,请先登录教务系统,再点击导入!非本校开发者,如有误请提交issues。"

+ 163 - 0
resources/HEBAU/hebau.js

@@ -0,0 +1,163 @@
+// 河北农业大学 (urp.hebau.edu.cn) 拾光课程表适配脚本
+// 金智教务 jwapp/sys/wdkb 模块,各接口均为 POST:
+//   学期列表 /jwapp/sys/wdkb/modules/jshkcb/xnxqcx.do      body: *order=-DM
+//   校历     /jwapp/sys/wdkb/modules/xskcb/cxxljc.do        body: XN=&XQ=
+//   课表     /jwapp/sys/wdkb/modules/xskcb/cxxszhxqkb.do    body: XNXQDM=
+//   作息     /jwapp/sys/wdkb/modules/jshkcb/jc.do           body: 空
+// 周次用 SKZC 位图:第 i 位为 '1' 表示第 i+1 周有课
+
+async function postForm(path, params) {
+    const response = await fetch(path, {
+        method: "POST",
+        credentials: "include",
+        headers: {
+            "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
+            "X-Requested-With": "XMLHttpRequest"
+        },
+        body: new URLSearchParams(params).toString()
+    });
+    if (!response.ok) throw new Error("接口请求失败(HTTP " + response.status + ")");
+    return response.json();
+}
+
+// 金智响应统一取 datas.<key>.rows
+function rowsOf(payload, key) {
+    const data = payload && payload.datas && payload.datas[key];
+    return data && Array.isArray(data.rows) ? data.rows : [];
+}
+
+// SKZC 位图 → 周次数组
+function parseWeeks(bitmap) {
+    const weeks = [];
+    const text = String(bitmap || "");
+    for (let i = 0; i < text.length; i++) {
+        if (text.charAt(i) === "1") weeks.push(i + 1);
+    }
+    return weeks;
+}
+
+// 学期列表没有「当前学期」标记,按日期推断作为默认选项:9月~次年1月为第一学期
+function inferYearTerm() {
+    const now = new Date();
+    const year = now.getFullYear();
+    const month = now.getMonth() + 1;
+    if (month >= 9) return year + "-" + (year + 1) + "-1";
+    if (month <= 1) return (year - 1) + "-" + year + "-1";
+    return (year - 1) + "-" + year + "-2";
+}
+
+// 每条课表行 = 一天的一段课,按「课程名+教师+星期+节次+教室」合并同键周次
+function parseCourses(tableRows) {
+    const map = new Map();
+    for (const row of tableRows) {
+        const name = String(row.KCM || "").trim();
+        const day = Number(row.SKXQ);
+        const startSection = Number(row.KSJC);
+        const endSection = Number(row.JSJC);
+        const weeks = parseWeeks(row.SKZC);
+        if (!name || day < 1 || day > 7 || !startSection || !endSection ||
+            startSection > endSection || weeks.length === 0) continue;
+
+        const teacher = String(row.SKJS || "").trim();
+        const position = String(row.JASMC || "").trim();
+        const key = [name, teacher, day, startSection, endSection, position].join("|");
+        const holder = map.get(key);
+        if (holder) {
+            holder.weeks = Array.from(new Set([...holder.weeks, ...weeks])).sort((a, b) => a - b);
+        } else {
+            map.set(key, { name, teacher, position, day, startSection, endSection, weeks });
+        }
+    }
+    return Array.from(map.values()).sort((a, b) =>
+        a.day - b.day || a.startSection - b.startSection || a.name.localeCompare(b.name));
+}
+
+async function promptUserToStart() {
+    return await window.shiguangBridgePromise.showAlert(
+        "教务系统课表导入", "导入前请确保您已在浏览器中成功登录河北农业大学教务系统", "好的,开始导入");
+}
+
+async function selectSemester(semesterRows, defaultTerm) {
+    const defaultIndex = semesterRows.findIndex(row => row.DM === defaultTerm);
+    const names = semesterRows.map(row => row.MC || row.DM);
+    const index = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学期", JSON.stringify(names), defaultIndex === -1 ? 0 : defaultIndex);
+    if (index === null || index === -1) return null;
+    return semesterRows[index];
+}
+
+// 校历:开学日期与总周数(ZZC 为总周数,取不到退回 ZJXZC)
+async function fetchSemesterConfig(term) {
+    try {
+        const payload = await postForm("/jwapp/sys/wdkb/modules/xskcb/cxxljc.do",
+            { XN: term.XNDM, XQ: term.XQDM });
+        const row = rowsOf(payload, "cxxljc")[0];
+        if (!row) return null;
+        const beginDate = String(row.XQKSRQ || "").split(" ")[0];
+        const totalWeeks = Number(row.ZZC) || Number(row.ZJXZC) || 0;
+        if (!/^\d{4}-\d{2}-\d{2}$/.test(beginDate) || !totalWeeks) return null;
+        return { semesterStartDate: beginDate, semesterTotalWeeks: totalWeeks };
+    } catch (e) { return null; }
+}
+
+async function fetchTimeSlots() {
+    try {
+        const payload = await postForm("/jwapp/sys/wdkb/modules/jshkcb/jc.do", {});
+        return rowsOf(payload, "jc").map(row => ({
+            number: row.DM,
+            startTime: row.KSSJ,
+            endTime: row.JSSJ
+        }));
+    } catch (e) { return []; }
+}
+
+async function saveCourses(courses) {
+    try {
+        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+        return true;
+    } catch (e) {
+        window.shiguangBridge.showToast("课程保存失败: " + e.message);
+        return false;
+    }
+}
+
+async function runImportFlow() {
+    if (!await promptUserToStart()) { window.shiguangBridge.showToast("用户取消了导入。"); return; }
+
+    try {
+        const semesterRows = rowsOf(
+            await postForm("/jwapp/sys/wdkb/modules/jshkcb/xnxqcx.do", { "*order": "-DM" }),
+            "xnxqcx"
+        ).filter(row => row && row.DM);
+        if (semesterRows.length === 0) throw new Error("未获取到学期列表。");
+
+        const term = await selectSemester(semesterRows, inferYearTerm());
+        if (!term) { window.shiguangBridge.showToast("未选择学期,导入终止。"); return; }
+
+        window.shiguangBridge.showToast("正在请求课表数据...");
+        const [tablePayload, config, timeSlots] = await Promise.all([
+            postForm("/jwapp/sys/wdkb/modules/xskcb/cxxszhxqkb.do", { XNXQDM: term.DM }),
+            fetchSemesterConfig(term),
+            fetchTimeSlots()
+        ]);
+
+        const courses = parseCourses(rowsOf(tablePayload, "cxxszhxqkb"));
+        if (courses.length === 0) throw new Error("未找到课程数据,请确认所选学期是否正确。");
+        if (!await saveCourses(courses)) return;
+
+        if (config) {
+            try { await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config)); } catch (e) {}
+        }
+        if (timeSlots.length > 0) {
+            try { await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots)); } catch (e) {}
+        }
+
+        window.shiguangBridge.showToast("课程导入成功,共导入 " + courses.length + " 门课程!");
+        window.shiguangBridge.notifyTaskCompletion();
+    } catch (e) {
+        window.shiguangBridge.showToast("导入失败:" + e.message);
+        console.error("JS: Import Error", e);
+    }
+}
+
+runImportFlow();

+ 9 - 0
resources/XHKJZYXY/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/XHKJZYXY/adapters.yaml
+adapters:
+  - adapter_id: "XHKJZYXY_01"
+    adapter_name: "宣化科技职业学院正方适配"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "xhkjzyxy_01.js"
+    import_url: "https://xhkjzyxy.edu.cn:18088/cas/login?service=https%3A%2F%2Fxhkjzyxy.edu.cn%3A8387%2Fsso%2Fjziotlogin"
+    maintainer: "星河欲转"
+    description: "宣化科技职业学院正方适配,支持获取开学日期,非本校开发者适配如果有误建议提交issues"

+ 559 - 0
resources/XHKJZYXY/xhkjzyxy_01.js

@@ -0,0 +1,559 @@
+// 宣化科技职业学院(xhkjzyxy.edu.cn) 拾光课程表适配脚本
+// 基于正方教务系统接口适配
+// 非该大学开发者适配,开发者无法及时发现问题
+// 出现问题请提issues或者提交pr更改,这更加快速
+
+const BASE_URLS = {
+    campus: "https://xhkjzyxy.edu.cn:8387/jwglxt",
+};
+
+function getBaseUrl() {
+    return BASE_URLS.campus;
+}
+
+/**
+ * 节次与周次合并去重函数
+ * @param {Array<Object>} courses 原始解析课程数组
+ * @returns {Array<Object>} 合并去重后的课程数组
+ */
+function mergeAndDistinctCourses(courses) {
+    if (!Array.isArray(courses) || courses.length <= 1) return courses;
+
+    // 1. 深拷贝并规范周次数据,过滤无效项
+    const list = courses.map(c => ({
+        ...c,
+        name: c.name || '',
+        teacher: c.teacher || '',
+        position: c.position || '',
+        weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
+    }));
+
+    // 阶段 1:合并连续节次与完全重复记录(前提:名称、教师、地点、星期、周次一致)
+    list.sort((a, b) => {
+        return a.name.localeCompare(b.name) ||
+               a.teacher.localeCompare(b.teacher) ||
+               a.position.localeCompare(b.position) ||
+               (a.day || 0) - (b.day || 0) ||
+               a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
+               (a.startSection || 0) - (b.startSection || 0);
+    });
+
+    const step1Merged = [];
+    let current = list[0];
+
+    for (let i = 1; i < list.length; i++) {
+        const next = list[i];
+
+        const isSameCourseAndWeeks =
+            current.name === next.name &&
+            current.teacher === next.teacher &&
+            current.position === next.position &&
+            current.day === next.day &&
+            current.weeks.join(',') === next.weeks.join(',');
+
+        const isContinuous = current.endSection + 1 === next.startSection;
+        const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
+
+        if (isSameCourseAndWeeks && isContinuous) {
+            // 节次连续:延长结束节次 (如 1-2 节 + 3-4 节 -> 1-4 节)
+            current.endSection = next.endSection;
+        } else if (isSameCourseAndWeeks && isDuplicate) {
+            // 完全重复:跳过
+            continue;
+        } else {
+            step1Merged.push(current);
+            current = next;
+        }
+    }
+    step1Merged.push(current);
+
+    // 阶段 2:合并同节次的周次(前提:名称、教师、地点、星期、开始/结束节次一致)
+    step1Merged.sort((a, b) => {
+        return a.name.localeCompare(b.name) ||
+               a.teacher.localeCompare(b.teacher) ||
+               a.position.localeCompare(b.position) ||
+               (a.day || 0) - (b.day || 0) ||
+               (a.startSection || 0) - (b.startSection || 0) ||
+               (a.endSection || 0) - (b.endSection || 0);
+    });
+
+    const step2Merged = [];
+    let cur = step1Merged[0];
+
+    for (let i = 1; i < step1Merged.length; i++) {
+        const nxt = step1Merged[i];
+
+        const isSameCourseAndSection =
+            cur.name === nxt.name &&
+            cur.teacher === nxt.teacher &&
+            cur.position === nxt.position &&
+            cur.day === nxt.day &&
+            cur.startSection === nxt.startSection &&
+            cur.endSection === nxt.endSection;
+
+        if (isSameCourseAndSection) {
+            // 周次合并去重 (如 1-8 周 + 9-16 周 -> 1-16 周)
+            cur.weeks = Array.from(new Set([...cur.weeks, ...nxt.weeks])).sort((a, b) => a - b);
+        } else {
+            step2Merged.push(cur);
+            cur = nxt;
+        }
+    }
+    step2Merged.push(cur);
+
+    return step2Merged;
+}
+
+/**
+ * 解析周次字符串,处理单双周和周次范围
+ */
+function parseWeeks(weekStr) {
+    if (!weekStr) return [];
+
+    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+)周/);
+
+        let start = 0;
+        let end = 0;
+        let processed = false;
+
+        if (rangeMatch) {
+            start = Number(rangeMatch[1]);
+            end = Number(rangeMatch[2]);
+            processed = true;
+        } else if (singleMatch) {
+            start = end = Number(singleMatch[1]);
+            processed = true;
+        }
+
+        if (processed) {
+            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);
+            }
+        }
+    }
+
+    return [...new Set(weeks)].sort((a, b) => a - b);
+}
+
+/**
+ * 解析 API 返回的 JSON 数据
+ */
+function parseJsonData(jsonData) {
+    if (!jsonData || !Array.isArray(jsonData.kbList)) {
+        return [];
+    }
+
+    const rawCourseList = jsonData.kbList;
+    const initialCourseList = [];
+
+    for (const rawCourse of rawCourseList) {
+        if (!rawCourse.kcmc || !rawCourse.xm || !rawCourse.cdmc ||
+            !rawCourse.xqj || !rawCourse.jcs || !rawCourse.zcd) {
+            continue;
+        }
+
+        const weeksArray = parseWeeks(rawCourse.zcd);
+        if (weeksArray.length === 0) {
+            continue;
+        }
+
+        const sectionParts = rawCourse.jcs.split('-');
+        const startSection = Number(sectionParts[0]);
+        const endSection = Number(sectionParts[sectionParts.length - 1]);
+        const day = Number(rawCourse.xqj);
+
+        if (isNaN(day) || isNaN(startSection) || isNaN(endSection) ||
+            day < 1 || day > 7 || startSection > endSection) {
+            continue;
+        }
+
+        initialCourseList.push({
+            name: rawCourse.kcmc.trim(),
+            teacher: rawCourse.xm.trim(),
+            position: rawCourse.cdmc.trim(),
+            day: day,
+            startSection: startSection,
+            endSection: endSection,
+            weeks: weeksArray
+        });
+    }
+
+    return mergeAndDistinctCourses(initialCourseList);
+}
+
+async function promptUserToStart() {
+    return await window.shiguangBridgePromise.showAlert(
+        "教务系统课表导入",
+        "导入前请确保您已在浏览器中成功登录教务系统",
+        "好的,开始导入"
+    );
+}
+
+/**
+ * 从教务系统获取学年学期选项
+ * 学年:以选中项为中心,取前2年+后2年,共5个选项
+ */
+async function fetchAcademicOptions() {
+    const url = `${getBaseUrl()}/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151&layout=default`;
+
+    try {
+        const response = await fetch(url, {
+            method: "GET",
+            credentials: "include"
+        });
+
+        if (!response.ok) return null;
+
+        const htmlText = await response.text();
+        const parser = new DOMParser();
+        const doc = parser.parseFromString(htmlText, "text/html");
+
+        const allYearOptions = Array.from(doc.querySelectorAll("#xnm option"))
+            .filter(opt => opt.value !== "")
+            .map(opt => ({
+                value: opt.value,
+                text: opt.textContent.trim(),
+                selected: opt.selected
+            }));
+
+        const semesterOptions = Array.from(doc.querySelectorAll("#xqm option"))
+            .filter(opt => opt.value !== "")
+            .map(opt => ({
+                value: opt.value,
+                text: opt.textContent.trim(),
+                selected: opt.selected
+            }));
+
+        if (allYearOptions.length === 0 || semesterOptions.length === 0) {
+            return null;
+        }
+
+        const selectedIndex = allYearOptions.findIndex(opt => opt.selected);
+
+        if (selectedIndex === -1) {
+            return {
+                yearOptions: allYearOptions.slice(0, 5),
+                semesterOptions,
+                defaultYearIndex: 0,
+                defaultSemesterIndex: semesterOptions.findIndex(opt => opt.selected) !== -1
+                    ? semesterOptions.findIndex(opt => opt.selected)
+                    : 0
+            };
+        }
+
+        const start = Math.max(0, selectedIndex - 2);
+        const end = Math.min(allYearOptions.length, selectedIndex + 3);
+        const yearOptions = allYearOptions.slice(start, end);
+        const newDefaultIndex = selectedIndex - start;
+        const defaultSemesterIndex = semesterOptions.findIndex(opt => opt.selected);
+
+        return {
+            yearOptions,
+            semesterOptions,
+            defaultYearIndex: newDefaultIndex,
+            defaultSemesterIndex: defaultSemesterIndex !== -1 ? defaultSemesterIndex : 0
+        };
+
+    } catch (e) {
+        return null;
+    }
+}
+
+/**
+ * 提示用户选择学年和学期
+ */
+async function selectAcademicYearAndSemester() {
+    const optionsData = await fetchAcademicOptions();
+
+    if (!optionsData) {
+        window.shiguangBridge.showToast("从教务系统读取学年学期失败,请确保登录状态。");
+        return null;
+    }
+
+    const { yearOptions, semesterOptions, defaultYearIndex, defaultSemesterIndex } = optionsData;
+
+    const yearTexts = yearOptions.map(item => item.text);
+    const yearIndex = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学年",
+        JSON.stringify(yearTexts),
+        defaultYearIndex
+    );
+
+    if (yearIndex === null || yearIndex === -1) return null;
+    const selectedYearCode = yearOptions[yearIndex].value;
+
+    const semesterTexts = semesterOptions.map(item => item.text);
+    const semesterIndex = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学期",
+        JSON.stringify(semesterTexts),
+        defaultSemesterIndex
+    );
+
+    if (semesterIndex === null || semesterIndex === -1) return null;
+    const selectedSemesterCode = semesterOptions[semesterIndex].value;
+
+    return {
+        academicYear: selectedYearCode,
+        semesterCode: selectedSemesterCode
+    };
+}
+
+/**
+ * 获取学期开学日期
+ */
+async function fetchSemesterStartDate(academicYear, semesterCode) {
+    const url = `${getBaseUrl()}/kbcx/xskbcxZccx_cxZcByXnxq.html?gnmkdm=N2154`;
+    const requestBody = `xnm=${academicYear}&xqm=${semesterCode}`;
+
+    try {
+        const response = await fetch(url, {
+            method: "POST",
+            headers: {
+                "accept": "application/json, text/javascript, */*; q=0.01",
+                "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
+                "x-requested-with": "XMLHttpRequest"
+            },
+            body: requestBody,
+            credentials: "include"
+        });
+
+        if (response.ok) {
+            const json = await response.json();
+            if (Array.isArray(json) && json.length > 0) {
+                // 优先找第1周,否则取第一项
+                const firstWeekObj = json.find(item => String(item.zs) === "1" || String(item.zsmc) === "1") || json[0];
+
+                if (firstWeekObj.rq) {
+                    const startDateStr = firstWeekObj.rq.split('/')[0];
+                    if (/^\d{4}-\d{2}-\d{2}$/.test(startDateStr)) {
+                        return startDateStr;
+                    }
+                }
+                if (firstWeekObj.zcrq) {
+                    const match = firstWeekObj.zcrq.match(/(\d{4}-\d{2}-\d{2})/);
+                    if (match) return match[1];
+                }
+                // 某些系统用 ksrq 字段
+                if (firstWeekObj.ksrq) {
+                    const match = firstWeekObj.ksrq.match(/(\d{4}-\d{2}-\d{2})/);
+                    if (match) return match[1];
+                }
+            }
+        }
+    } catch (e) {
+        // 获取失败不影响主流程
+    }
+    return null;
+}
+
+/**
+ * 从教务系统获取作息时间表(节次时间)
+ * @param {string} academicYear 学年
+ * @param {string} semesterCode 学期代码
+ * @returns {Promise<Array<{number: number, startTime: string, endTime: string}>>}
+ */
+async function fetchTimeSlots(academicYear, semesterCode) {
+    const url = `${getBaseUrl()}/jzgl/skxxMobile_cxRsdjc.html?gnmkdm=N2154`;
+    const requestBody = `xnm=${academicYear}&xqm=${semesterCode}&xqh_id=1`;
+
+    try {
+        const response = await fetch(url, {
+            method: "POST",
+            headers: {
+                "accept": "*/*",
+                "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
+                "x-requested-with": "XMLHttpRequest"
+            },
+            body: requestBody,
+            credentials: "include"
+        });
+
+        if (!response.ok) return null;
+
+        const json = await response.json();
+
+        if (!Array.isArray(json) || json.length === 0) {
+            return null;
+        }
+
+        const seen = {};
+        const timeSlots = [];
+
+        for (let i = 0; i < json.length; i++) {
+            const item = json[i];
+            if (!item || !item.jcmc || !item.qssj || !item.jssj) {
+                continue;
+            }
+
+            const num = Number(item.jcmc);
+            if (isNaN(num) || seen[num]) {
+                continue;
+            }
+            seen[num] = true;
+
+            timeSlots.push({
+                number: num,
+                startTime: String(item.qssj).substring(0, 5),
+                endTime: String(item.jssj).substring(0, 5)
+            });
+        }
+
+        timeSlots.sort((a, b) => a.number - b.number);
+
+        return timeSlots.length > 0 ? timeSlots : null;
+
+    } catch (e) {
+        return null;
+    }
+}
+
+/**
+ * 请求和解析课程数据
+ */
+async function fetchAndParseCourses(academicYear, semesterCode) {
+    const requestBody = `xnm=${academicYear}&xqm=${semesterCode}&kzlx=ck&xsdm=&kclbdm=`;
+    const targetUrl = `${getBaseUrl()}/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151`;
+
+    // 并行获取课程数据、开学日期、作息时间
+    const [courseResponse, semesterStartDate, fetchedTimeSlots] = await Promise.all([
+        fetch(targetUrl, {
+            method: "POST",
+            headers: {
+                "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
+            },
+            body: requestBody,
+            credentials: "include"
+        }),
+        fetchSemesterStartDate(academicYear, semesterCode),
+        fetchTimeSlots(academicYear, semesterCode)
+    ]);
+
+    try {
+        if (courseResponse.ok) {
+            const jsonText = await courseResponse.text();
+            const jsonData = JSON.parse(jsonText);
+            if (jsonData && jsonData.kbList) {
+                const parsedCourses = parseJsonData(jsonData);
+                if (parsedCourses.length > 0) {
+                    return {
+                        courses: parsedCourses,
+                        config: {
+                            semesterStartDate: semesterStartDate,
+                            semesterTotalWeeks: 20
+                        },
+                        timeSlots: fetchedTimeSlots
+                    };
+                }
+            }
+        }
+    } catch (e) {
+        // 请求失败
+    }
+
+    window.shiguangBridge.showToast("未能获取课表数据,请检查网络环境或登录状态。");
+    return null;
+}
+
+async function saveCourses(parsedCourses) {
+    try {
+        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedCourses));
+        return true;
+    } catch (error) {
+        window.shiguangBridge.showToast(`课程保存失败: ${error.message}`);
+        return false;
+    }
+}
+
+// 优先使用从教务系统接口动态获取的作息时间,获取失败时回退到此表
+const FALLBACK_TIME_SLOTS = [
+    { number: 1,  startTime: "08:30", endTime: "09:15" },
+    { number: 2,  startTime: "09:15", endTime: "10:00" },
+    { number: 3,  startTime: "10:20", endTime: "11:05" },
+    { number: 4,  startTime: "11:05", endTime: "11:50" },
+    { number: 5,  startTime: "14:30", endTime: "15:15" },
+    { number: 6,  startTime: "15:15", endTime: "16:00" },
+    { number: 7,  startTime: "16:20", endTime: "17:05" },
+    { number: 8,  startTime: "17:05", endTime: "17:50" },
+    { number: 9,  startTime: "19:00", endTime: "19:45" },
+    { number: 10, startTime: "19:45", endTime: "20:30" }
+];
+
+async function importPresetTimeSlots(timeSlots) {
+    let finalTimeSlots = timeSlots;
+    let usedFallback = false;
+
+    if (!finalTimeSlots || finalTimeSlots.length === 0) {
+        // 动态获取失败,回退到硬编码保底方案
+        finalTimeSlots = FALLBACK_TIME_SLOTS;
+        usedFallback = true;
+    }
+
+    try {
+        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(finalTimeSlots));
+        if (usedFallback) {
+            window.shiguangBridge.showToast("未获取到教务系统作息,已使用内置保底作息时间导入。");
+        } else {
+            window.shiguangBridge.showToast("预设时间段导入成功!");
+        }
+    } catch (error) {
+        window.shiguangBridge.showToast("导入时间段失败: " + error.message);
+    }
+}
+
+async function runImportFlow() {
+    const alertConfirmed = await promptUserToStart();
+    if (!alertConfirmed) {
+        window.shiguangBridge.showToast("用户取消了导入。");
+        return;
+    }
+
+    const selection = await selectAcademicYearAndSemester();
+    if (!selection) {
+        window.shiguangBridge.showToast("未选择学年学期,导入流程终止。");
+        return;
+    }
+
+    const { academicYear, semesterCode } = selection;
+
+    const result = await fetchAndParseCourses(academicYear, semesterCode);
+    if (result === null) {
+        return;
+    }
+
+    const { courses, config, timeSlots } = result;
+
+    const saveResult = await saveCourses(courses);
+    if (!saveResult) {
+        return;
+    }
+
+    try {
+        await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+        let configMsg = "课表配置更新成功!";
+        if (config.semesterStartDate) {
+            configMsg += ` 开学日期:${config.semesterStartDate}`;
+        }
+        window.shiguangBridge.showToast(configMsg);
+    } catch (error) {
+        window.shiguangBridge.showToast(`课表配置保存失败: ${error.message}`);
+    }
+
+    await importPresetTimeSlots(timeSlots);
+
+    window.shiguangBridge.showToast(`课程导入成功,共导入 ${courses.length} 门课程!`);
+    window.shiguangBridge.notifyTaskCompletion();
+}
+
+runImportFlow();