Просмотр исходного кода

Merge pull request #665 from XingHeYuZhuan/pending

星河欲转 1 неделя назад
Родитель
Сommit
d3e8f139cc

+ 10 - 0
index/root_index.yaml

@@ -68,6 +68,11 @@ schools:
     initial: "C"
     resource_folder: "CUIT"
 
+  - id: "CDUTCM"
+    name: "成都中医药大学"
+    initial: "C"
+    resource_folder: "CDUTCM"
+
   - id: "SHZQ"
     name: "上海中侨职业技术大学"
     initial: "S"
@@ -118,6 +123,11 @@ schools:
     initial: "M"
     resource_folder: "MMPT"
 
+  - id: "MYSY"
+    name: "绵阳师范学院"
+    initial: "M"
+    resource_folder: "MYSY"
+    
   - id: "MKU"
     name: "闽南科技学院"
     initial: "M"

+ 10 - 0
resources/CDUTCM/adapters.yaml

@@ -0,0 +1,10 @@
+adapters:
+  - adapter_id: "CDUTCM_01"
+    adapter_name: "成都中医药大学教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "cdutcm_01.js"
+    import_url: "https://jwweb.cdutcm.edu.cn/"
+    maintainer: "wwwhyDB"
+    description: |
+      登录教务系统后一键导入完整课表,自动附带上课时间。
+      支持单双周、多教师多地点等课程;导入时会询问学期开始日期。

+ 388 - 0
resources/CDUTCM/cdutcm_01.js

@@ -0,0 +1,388 @@
+// CDUTCM_01.js
+// 成都中医药大学教务系统适配脚本
+// 系统厂商:广州乘方科技有限公司(CFKJ / entss)
+//
+// 数据源(两个接口组合):
+// 1. 课程任务列表:/xsgrkbcx!xsAllKbList.action?xnxqdm=YYYYNN
+//    字段:kcmc, kcbh, jxbmc, kcrwdm, jcdm2, zcs(数组), xq, jxcdmcs(备选), teaxms
+//    用途:教师(teaxms)、班分子(jxbmc 中的 [N-M])、课程编号
+//
+// 2. 排课详情:/xsgrkbcx!getSkxxDataList.action?kcrwdm=X&teadm=
+//    字段:kxh, zc(单值), xq, jcdm2, kcmc, jxcdmc(精确), jxbmc, sknrjj
+//    用途:精确教室(jxcdmc),按单次具体排课展开
+//
+// 关联键:(kcmc + xq + jcdm2) 三元组
+// 36 条课程任务只对应 13 个不同 kcrwdm,并发 13 次 fetch 即覆盖全部排课详情
+
+// ===== 解析周次字符串为升序 number[] =====
+function parseWeeks(zcs) {
+    if (!zcs) return [];
+    const set = new Set();
+    String(zcs).split(',').forEach(part => {
+        const n = parseInt(String(part).trim(), 10);
+        if (!isNaN(n) && n > 0) set.add(n);
+    });
+    return Array.from(set).sort((a, b) => a - b);
+}
+
+// ===== 解析节次字符串为 number[](保留 0,用于识别早读)=====
+function parsePeriods(jcdm2) {
+    if (!jcdm2) return [];
+    return String(jcdm2).split(',')
+        .map(s => parseInt(String(s).trim(), 10))
+        .filter(n => !isNaN(n));
+}
+
+// ===== 拆分教学班名称中的 [N-M] 班分子 =====
+function splitClassInfo(jxbmc) {
+    const text = String(jxbmc || '').trim();
+    const m = text.match(/^(.*?)\[(\d+)-(\d+)\]\s*$/);
+    if (m) {
+        return { base: m[1].trim(), range: `${m[2]}-${m[3]}` };
+    }
+    return { base: text, range: '' };
+}
+
+// ===== 早读默认时段 =====
+const MORNING_READING_START = '08:00';
+const MORNING_READING_END = '08:20';
+
+// ===== 时间模板(节次时间表,CDUTCM 教务不直接暴露)=====
+// 推导规则:每节 40 分钟,节间 10 分钟
+//   00 早读:08:00-08:20
+//   01-05 上午:08:30 起
+//   06-09 下午:14:00 起
+//   10-12 晚上:18:30 起
+const TIME_SLOTS = [
+    { number: 0,  startTime: '08:00', endTime: '08:20' },
+    { number: 1,  startTime: '08:30', endTime: '09:10' },
+    { number: 2,  startTime: '09:20', endTime: '10:00' },
+    { number: 3,  startTime: '10:10', endTime: '10:50' },
+    { number: 4,  startTime: '11:00', endTime: '11:40' },
+    { number: 5,  startTime: '11:50', endTime: '12:30' },
+    { number: 6,  startTime: '14:00', endTime: '14:40' },
+    { number: 7,  startTime: '14:50', endTime: '15:30' },
+    { number: 8,  startTime: '15:40', endTime: '16:20' },
+    { number: 9,  startTime: '16:30', endTime: '17:10' },
+    { number: 10, startTime: '18:30', endTime: '19:10' },
+    { number: 11, startTime: '19:20', endTime: '20:00' },
+    { number: 12, startTime: '20:10', endTime: '20:50' }
+];
+
+// ===== 学期配置 =====
+const SEMESTER_DEFAULT_START_DATE = '2026-08-31';
+const SEMESTER_TOTAL_WEEKS = 22;
+const DEFAULT_CLASS_DURATION = 40;
+const DEFAULT_BREAK_DURATION = 10;
+const FIRST_DAY_OF_WEEK = 1;
+
+function isValidDateString(s) {
+    if (!s) return false;
+    const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})$/);
+    if (!m) return false;
+    const year = parseInt(m[1], 10);
+    const month = parseInt(m[2], 10);
+    const day = parseInt(m[3], 10);
+    if (month < 1 || month > 12 || day < 1 || day > 31) return false;
+    const date = new Date(s);
+    if (isNaN(date.getTime())) return false;
+    return date.getFullYear() === year
+        && date.getMonth() + 1 === month
+        && date.getDate() === day;
+}
+
+async function promptSemesterStartDate() {
+    while (true) {
+        const input = await window.shiguangBridgePromise.showPrompt(
+            '学期开始日期',
+            '请输入本学期第一周周一的日期(YYYY-MM-DD)',
+            SEMESTER_DEFAULT_START_DATE,
+            null
+        );
+        if (input === null) return null;
+        if (isValidDateString(input)) return input;
+        await window.shiguangBridgePromise.showAlert(
+            '日期格式错误',
+            `请输入 YYYY-MM-DD 格式,例如 ${SEMESTER_DEFAULT_START_DATE}`,
+            '重试'
+        );
+    }
+}
+
+// ===== HTTP 工具 =====
+async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+    try {
+        return await fetch(url, Object.assign({}, options, { signal: controller.signal }));
+    } finally {
+        clearTimeout(timeoutId);
+    }
+}
+
+const COMMON_HEADERS = {
+    'Referer': 'https://jwweb.cdutcm.edu.cn/xsgrkbcx!xsgrkbMain.action',
+    'X-Requested-With': 'XMLHttpRequest'
+};
+
+// ===== 抓取课程任务列表(含教师、班分子、周次数组)=====
+async function fetchKbTaskList(xnxqdm) {
+    const url = `/xsgrkbcx!xsAllKbList.action?xnxqdm=${encodeURIComponent(xnxqdm)}`;
+    const response = await fetchWithTimeout(url, {
+        method: 'GET',
+        credentials: 'include',
+        headers: COMMON_HEADERS
+    });
+    if (!response.ok) throw new Error(`课程任务列表请求失败(HTTP ${response.status})`);
+    const text = await response.text();
+    return extractKbxx(text);
+}
+
+// ===== 抓取学期下拉框(用于让用户选学期)=====
+async function fetchXsgrkbListPage() {
+    const currentYear = new Date().getFullYear();
+    const candidateXnxqdm = [`${currentYear}01`, `${currentYear - 1}01`];
+    let lastError = null;
+    for (const xnxqdm of candidateXnxqdm) {
+        try {
+            const response = await fetchWithTimeout(
+                `/xsgrkbcx!getXsgrbkList.action?xnxqdm=${encodeURIComponent(xnxqdm)}`,
+                { method: 'GET', credentials: 'include', headers: COMMON_HEADERS }
+            );
+            if (!response.ok) { lastError = `HTTP ${response.status}`; continue; }
+            const text = await response.text();
+            if (extractSemesterOptions(text)) return text;
+        } catch (e) {
+            lastError = e.message || String(e);
+        }
+    }
+    throw new Error(
+        `无法获取学期下拉框(${lastError || '无可用学期码'})。` +
+        `请先在浏览器里手动打开「信息查询 → 学生个人课表查询 → 我的课表」后再运行脚本。`
+    );
+}
+
+// ===== 抓取单个教学任务的精确排课(并发调用)=====
+async function fetchSkxx(kcrwdm) {
+    const url = `/xsgrkbcx!getSkxxDataList.action?kcrwdm=${encodeURIComponent(kcrwdm)}&teadm=`;
+    const response = await fetchWithTimeout(url, {
+        method: 'GET',
+        credentials: 'include',
+        headers: COMMON_HEADERS
+    });
+    if (!response.ok) throw new Error(`getSkxxDataList 失败(HTTP ${response.status})`);
+    const json = await response.json();
+    return json.rows || [];
+}
+
+// ===== 并发抓取所有 kcrwdm 的精确排课 =====
+async function fetchAllSkxx(kcrwdms) {
+    const results = await Promise.allSettled(kcrwdms.map(k => fetchSkxx(k)));
+    const allRows = [];
+    const failed = [];
+    results.forEach((r, i) => {
+        if (r.status === 'fulfilled') {
+            allRows.push(...r.value);
+        } else {
+            failed.push(kcrwdms[i]);
+        }
+    });
+    return { rows: allRows, failed };
+}
+
+// ===== HTML/JSON 解析 =====
+function extractSemesterOptions(htmlText) {
+    const selectMatch = htmlText.match(/<select[^>]*id=['"]xnxqdm['"][^>]*>([\s\S]*?)<\/select>/i);
+    if (!selectMatch) return null;
+    const options = [];
+    const optionRe = /<option[^>]*value=['"]([^'"]+)['"]([^>]*)>([\s\S]*?)<\/option>/gi;
+    let m;
+    while ((m = optionRe.exec(selectMatch[1])) !== null) {
+        const value = (m[1] || '').trim();
+        const attrs = m[2] || '';
+        const label = (m[3] || '').replace(/<[^>]+>/g, '').trim();
+        if (!value || !label) continue;
+        const selected = /selected/i.test(attrs);
+        options.push({ value, label, selected });
+    }
+    if (options.length === 0) return null;
+    return options;
+}
+
+function extractKbxx(htmlText) {
+    const match = htmlText.match(/(?:var\s+)?kbxx\s*=\s*(\[[\s\S]*?\])\s*;/);
+    if (!match || !match[1]) return null;
+    try { return JSON.parse(match[1]); }
+    catch (e) { console.warn('CDUTCM: kbxx JSON.parse failed', e); return null; }
+}
+
+// ===== 把 skxx rows + kbxx 任务合并成最终课程列表 =====
+// 关联键:kcmc + xq + jcdm2
+function mergeToCourses(skxxRows, kbxxTasks) {
+    // 把 kbxx 按 (kcmc, xq, jcdm2) 索引,便于查找教师/班分子
+    const kbxxIndex = new Map();
+    kbxxTasks.forEach(t => {
+        const key = `${t.kcmc}__${t.xq}__${t.jcdm2}`;
+        if (!kbxxIndex.has(key)) kbxxIndex.set(key, t);
+    });
+
+    const courses = [];
+    const seen = new Set();
+
+    skxxRows.forEach(row => {
+        const day = parseInt(String(row.xq || '').trim(), 10);
+        if (isNaN(day) || day < 1 || day > 7) return;
+
+        const name = String(row.kcmc || '').trim();
+        if (!name) return;
+
+        const periods = parsePeriods(row.jcdm2);
+        if (periods.length === 0) return;
+
+        const zc = parseInt(String(row.zc || '').trim(), 10);
+        if (isNaN(zc) || zc < 1) return;
+
+        // 关联到 kbxx 任务(拿教师、班分子)
+        const key = `${name}__${row.xq}__${row.jcdm2}`;
+        const task = kbxxIndex.get(key);
+        const teacher = task ? String(task.teaxms || '').trim() : '';
+        const classInfo = task ? splitClassInfo(task.jxbmc) : { base: '', range: '' };
+
+        const noteParts = [];
+        if (classInfo.base) noteParts.push(classInfo.base);
+        if (classInfo.range) noteParts.push(`班分子 ${classInfo.range}`);
+        if (row.sknrjj) noteParts.push(String(row.sknrjj).trim());
+        const note = noteParts.join(' · ');
+
+        // 精确教室(jxcdmc)作为 position
+        // 空教室时(如某些实习课)兜底为「不用场地」
+        const position = String(row.jxcdmc || '').trim() || '不用场地';
+
+        const baseFields = {
+            name,
+            teacher,
+            position,
+            day,
+            weeks: [zc],
+            description: note,
+            note,
+            location: position,
+            dayOfWeek: day,
+            startWeek: zc,
+            endWeek: zc
+        };
+
+        // 去重 key(含 jxcdmc,避免同课同节次同周不同教室被去重)
+        const dedupKey = `${name}__${teacher}__${position}__${day}__${row.jcdm2}__${zc}`;
+        if (seen.has(dedupKey)) return;
+        seen.add(dedupKey);
+
+        // 早读分支
+        if (periods.length === 1 && periods[0] === 0) {
+            courses.push(Object.assign({}, baseFields, {
+                isCustomTime: true,
+                customStartTime: MORNING_READING_START,
+                customEndTime: MORNING_READING_END
+            }));
+            return;
+        }
+
+        // 普通节次
+        const startSection = Math.min(...periods);
+        const endSection = Math.max(...periods);
+        if (startSection > endSection) return;
+        courses.push(Object.assign({}, baseFields, {
+            startSection,
+            endSection,
+            courseNature: undefined
+        }));
+    });
+
+    return courses.sort((a, b) =>
+        a.day - b.day ||
+        (a.startSection || 0) - (b.startSection || 0) ||
+        (a.endSection || 0) - (b.endSection || 0) ||
+        a.name.localeCompare(b.name)
+    );
+}
+
+// ===== 主流程 =====
+async function runImportFlow() {
+    try {
+        shiguangBridge.showToast('开始读取课表入口...');
+
+        const listHtml = await fetchXsgrkbListPage();
+        const semesters = extractSemesterOptions(listHtml);
+        if (!semesters) throw new Error('未找到学期下拉框');
+
+        const defaultIndex = Math.max(0, semesters.findIndex(s => s.selected));
+        const labels = semesters.map(s => s.label);
+        const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
+            '选择学期',
+            JSON.stringify(labels),
+            defaultIndex
+        );
+        if (selectedIndex === null || selectedIndex < 0) {
+            shiguangBridge.showToast('已取消导入');
+            return;
+        }
+
+        const xnxqdm = semesters[selectedIndex].value;
+        shiguangBridge.showToast(`正在获取 ${semesters[selectedIndex].label} 课表与精确教室...`);
+
+        // 1. 抓取课程任务列表(含教师、班分子)
+        const kbxxTasks = await fetchKbTaskList(xnxqdm);
+        if (!kbxxTasks || kbxxTasks.length === 0) {
+            throw new Error('该学期未解析到课程任务,请确认登录状态和所选学期');
+        }
+
+        // 2. 提取所有不同 kcrwdm,并发抓精确教室
+        const kcrwdms = Array.from(new Set(kbxxTasks.map(t => String(t.kcrwdm || '').trim()).filter(Boolean)));
+        if (kcrwdms.length === 0) {
+            throw new Error('未找到 kcrwdm 字段');
+        }
+        const { rows: skxxRows, failed: failedKcrwdms } = await fetchAllSkxx(kcrwdms);
+        if (skxxRows.length === 0) {
+            throw new Error('未获取到精确教室数据,请确认登录状态');
+        }
+
+        // 3. 合并生成最终课程列表
+        const courses = mergeToCourses(skxxRows, kbxxTasks);
+        if (courses.length === 0) {
+            throw new Error('未能转换为有效课程');
+        }
+
+        // 4. 保存
+        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(TIME_SLOTS));
+
+        const semesterStartDate = await promptSemesterStartDate();
+        if (!semesterStartDate) {
+            shiguangBridge.showToast('已取消(未提供学期开始日期)');
+            return;
+        }
+        await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
+            semesterStartDate,
+            semesterTotalWeeks: SEMESTER_TOTAL_WEEKS,
+            defaultClassDuration: DEFAULT_CLASS_DURATION,
+            defaultBreakDuration: DEFAULT_BREAK_DURATION,
+            firstDayOfWeek: FIRST_DAY_OF_WEEK
+        }));
+
+        let msg = `Skxx 共 ${skxxRows.length} 条(精确教室),生成 ${courses.length} 门课程`;
+        if (failedKcrwdms.length > 0) {
+            msg += `(${failedKcrwdms.length} 个教学任务未拿到精确教室:${failedKcrwdms.join(', ')})`;
+        }
+        shiguangBridge.showToast(msg);
+        shiguangBridge.notifyTaskCompletion();
+    } catch (error) {
+        console.error('CDUTCM import failed:', error);
+        await window.shiguangBridgePromise.showAlert(
+            '导入失败',
+            error && error.message ? error.message : String(error),
+            '确定'
+        );
+    }
+}
+
+runImportFlow();

+ 9 - 0
resources/MYSY/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/MYSY/adapters.yaml
+adapters:
+  - adapter_id: "MYSY"
+    adapter_name: "绵阳师范学院教务系统"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "mysy.js"
+    import_url: "http://jw.mtc.edu.cn/"
+    maintainer: "Guzheng3"
+    description: "登录教务系统后,在个人课表页面一键导入课表,自动附带上课时间。"

+ 353 - 0
resources/MYSY/mysy.js

@@ -0,0 +1,353 @@
+// 绵阳师范学院正方教务系统课表适配脚本
+// 页面为 frameset 结构,课表主体在 iframe[name="Frame1"] 的 #kbtable 中。
+
+function showToast(message) {
+  try {
+    const bridge = window.shiguangBridge;
+    if (bridge && typeof bridge.showToast === 'function') {
+      bridge.showToast(message);
+    }
+  } catch (error) {
+    console.error('[MYSY] showToast failed:', error);
+  }
+}
+
+function getScheduleDocument() {
+  if (document.querySelector && document.querySelector('#kbtable')) {
+    return document;
+  }
+
+  const frames = [
+    document.querySelector('iframe[name="Frame1"]'),
+    document.querySelector('frame[name="Frame1"]')
+  ];
+
+  for (const frame of frames) {
+    if (!frame) continue;
+    try {
+      const doc = frame.contentDocument || frame.contentWindow.document;
+      if (doc && doc.querySelector('#kbtable')) return doc;
+    } catch (error) {
+      console.warn('[MYSY] Unable to access Frame1 document:', error);
+    }
+  }
+
+  try {
+    const namedFrame = window.frames && window.frames['Frame1'];
+    if (namedFrame && namedFrame.document && namedFrame.document.querySelector('#kbtable')) {
+      return namedFrame.document;
+    }
+  } catch (error) {
+    console.warn('[MYSY] Unable to access named Frame1:', error);
+  }
+
+  return null;
+}
+
+function waitForScheduleTable(timeoutMs) {
+  const timeout = timeoutMs || 15000;
+  const start = Date.now();
+
+  return new Promise((resolve) => {
+    const check = () => {
+      const doc = getScheduleDocument();
+      if (doc && doc.querySelector('#kbtable')) {
+        resolve(doc);
+        return;
+      }
+      if (Date.now() - start >= timeout) {
+        resolve(getScheduleDocument());
+        return;
+      }
+      setTimeout(check, 250);
+    };
+    check();
+  });
+}
+
+function parseWeeks(weekStr) {
+  if (!weekStr) return [];
+
+  const cleaned = String(weekStr)
+    .replace(/\[[^\]]*节[^\]]*\]/g, '')
+    .replace(/\s+/g, '');
+
+  const weeks = [];
+  const parts = cleaned.split(/[,,]/).filter(Boolean);
+
+  for (const part of parts) {
+    const parityMatch = part.match(/[((](单|双)[))]/);
+    const parity = parityMatch ? parityMatch[1] : null;
+    const numeric = part
+      .replace(/[((](单|双)[))]/g, '')
+      .replace(/[((]周[))]/g, '')
+      .replace(/周/g, '');
+    const match = numeric.match(/^(\d+)(?:[-~到](\d+))?$/);
+
+    if (!match) continue;
+
+    const start = Number(match[1]);
+    const end = match[2] ? Number(match[2]) : start;
+
+    for (let week = start; week <= end; week++) {
+      if (parity === '单' && week % 2 === 0) continue;
+      if (parity === '双' && week % 2 === 1) continue;
+      weeks.push(week);
+    }
+  }
+
+  return Array.from(new Set(weeks)).sort((a, b) => a - b);
+}
+
+function parseSectionRange(text) {
+  const match = String(text || '').match(/\[(\d{1,2})(?:\s*[-~]\s*(\d{1,2}))?节\]/);
+  if (!match) return null;
+
+  return {
+    start: Number(match[1]),
+    end: Number(match[2] || match[1])
+  };
+}
+
+function getDirectText(element) {
+  const parts = [];
+
+  for (const node of element.childNodes) {
+    if (node.nodeType !== 3) continue;
+    const text = (node.textContent || '').replace(/\s+/g, ' ').trim();
+    if (text) parts.push(text);
+  }
+
+  return parts.join(' ').trim();
+}
+
+function getTitledText(element, title) {
+  const target = element.querySelector(`font[title="${title}"]`);
+  if (!target) return '';
+  return (target.textContent || '').replace(/\s+/g, ' ').trim();
+}
+
+function parseCourseDiv(div) {
+  const text = (div.textContent || '').replace(/\s+/g, ' ').trim();
+  if (!text) return null;
+
+  const idParts = (div.getAttribute('id') || '').split('_');
+  const day = Number(idParts[1]) || 0;
+  const name = getDirectText(div);
+  const teacher = getTitledText(div, '老师') || '待定';
+  const position = getTitledText(div, '教室') || '待定';
+  const timeText = getTitledText(div, '周次(节次)');
+  const weeks = parseWeeks(timeText);
+
+  if (!name || weeks.length === 0 || day < 1 || day > 7) return null;
+
+  const section = parseSectionRange(timeText);
+  const course = {
+    name: name,
+    teacher: teacher,
+    position: position,
+    day: day,
+    startSection: section ? section.start : 0,
+    endSection: section ? section.end : 0,
+    weeks: weeks
+  };
+
+  return course;
+}
+
+function extractCourses(doc) {
+  const table = doc.querySelector('#kbtable');
+  if (!table) return [];
+
+  const courses = [];
+  const seen = new Set();
+
+  table.querySelectorAll('div.kbcontent').forEach((div) => {
+    const course = parseCourseDiv(div);
+    if (!course) return;
+
+    const key = JSON.stringify(course);
+    if (seen.has(key)) return;
+    seen.add(key);
+    courses.push(course);
+  });
+
+  courses.sort((a, b) => {
+    if (a.day !== b.day) return a.day - b.day;
+    if (a.startSection !== b.startSection) return a.startSection - b.startSection;
+    return a.name.localeCompare(b.name);
+  });
+
+  return courses;
+}
+
+function toMinutes(hhmm) {
+  const parts = String(hhmm).split(':').map(Number);
+  return parts[0] * 60 + parts[1];
+}
+
+function toHHMM(totalMinutes) {
+  const hours = Math.floor(totalMinutes / 60);
+  const minutes = totalMinutes % 60;
+  return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
+}
+
+function generateTimeSlots(doc) {
+  const fallback = [
+    { number: 1, startTime: '08:00', endTime: '08:45' },
+    { number: 2, startTime: '08:50', endTime: '09:35' },
+    { number: 3, startTime: '09:55', endTime: '10:40' },
+    { number: 4, startTime: '10:45', endTime: '11:30' },
+    { number: 5, startTime: '11:35', endTime: '12:20' },
+    { number: 6, startTime: '14:00', endTime: '14:45' },
+    { number: 7, startTime: '14:50', endTime: '15:35' },
+    { number: 8, startTime: '15:55', endTime: '16:40' },
+    { number: 9, startTime: '16:45', endTime: '17:30' },
+    { number: 10, startTime: '17:35', endTime: '18:20' },
+    { number: 11, startTime: '19:00', endTime: '19:45' },
+    { number: 12, startTime: '19:50', endTime: '20:35' },
+    { number: 13, startTime: '20:40', endTime: '21:25' }
+  ];
+
+  const table = doc.querySelector('#kbtable');
+  if (!table) return fallback;
+
+  const blocks = [];
+  table.querySelectorAll('tr th[rowspan]').forEach((th) => {
+    const text = (th.textContent || '').replace(/\s+/g, ' ').trim();
+    const match = text.match(/(\d{1,2}):(\d{2})\s*[-~]\s*(\d{1,2}):(\d{2})/);
+    if (!match) return;
+
+    const rowspan = Number(th.getAttribute('rowspan')) || 1;
+    blocks.push({
+      rowspan: rowspan,
+      start: `${match[1]}:${match[2]}`,
+      end: `${match[3]}:${match[4]}`
+    });
+  });
+
+  if (blocks.length === 0) return fallback;
+
+  const slots = [];
+  let number = 1;
+  const classDuration = 45;
+  const breakDuration = 5;
+
+  for (const block of blocks) {
+    const count = Math.max(1, block.rowspan);
+    let cursor = toMinutes(block.start);
+
+    for (let i = 0; i < count; i++) {
+      const start = cursor;
+      const end = start + classDuration;
+      slots.push({
+        number: number,
+        startTime: toHHMM(start),
+        endTime: toHHMM(end)
+      });
+      number++;
+      cursor = end + (i < count - 1 ? breakDuration : 0);
+    }
+  }
+
+  return slots.length > 0 ? slots : fallback;
+}
+
+async function saveCourses(courses) {
+  try {
+    if (!window.shiguangBridgePromise || typeof window.shiguangBridgePromise.saveImportedCourses !== 'function') {
+      throw new Error('saveImportedCourses bridge not found');
+    }
+    await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+    return true;
+  } catch (error) {
+    console.error('[MYSY] save courses failed:', error);
+    showToast(`课表保存失败: ${error.message}`);
+    return false;
+  }
+}
+
+async function saveTimeSlots(slots) {
+  try {
+    if (!window.shiguangBridgePromise || typeof window.shiguangBridgePromise.savePresetTimeSlots !== 'function') {
+      throw new Error('savePresetTimeSlots bridge not found');
+    }
+    await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(slots));
+    return true;
+  } catch (error) {
+    console.error('[MYSY] save time slots failed:', error);
+    showToast(`时间模板保存失败: ${error.message}`);
+    return false;
+  }
+}
+
+async function saveCourseConfig() {
+  try {
+    if (!window.shiguangBridgePromise || typeof window.shiguangBridgePromise.saveCourseConfig !== 'function') {
+      return;
+    }
+    await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
+      semesterTotalWeeks: 20,
+      defaultClassDuration: 45,
+      defaultBreakDuration: 5,
+      firstDayOfWeek: 1
+    }));
+  } catch (error) {
+    console.warn('[MYSY] save course config failed:', error);
+  }
+}
+
+async function runImportFlow() {
+  console.log('[MYSY] 开始导入绵阳师范学院课表...');
+  showToast('正在检查课表页面...');
+
+  const doc = await waitForScheduleTable(15000);
+  if (!doc || !doc.querySelector('#kbtable')) {
+    showToast('未找到课表,请先打开“学期理论课表”并确认已加载');
+    return;
+  }
+
+  const courses = extractCourses(doc);
+  if (courses.length === 0) {
+    showToast('未找到已安排上课时间的课程');
+    return;
+  }
+
+  try {
+    const confirmed = await window.shiguangBridgePromise.showAlert(
+      '教务系统课表导入',
+      `检测到 ${courses.length} 门课程,是否导入?`,
+      '确认导入'
+    );
+    if (!confirmed) {
+      showToast('已取消导入');
+      return;
+    }
+  } catch (error) {
+    console.warn('[MYSY] confirmation dialog unavailable:', error);
+  }
+
+  if (!(await saveCourses(courses))) return;
+
+  const timeSlots = generateTimeSlots(doc);
+  if (!(await saveTimeSlots(timeSlots))) return;
+
+  await saveCourseConfig();
+
+  showToast(`课表导入成功,共导入 ${courses.length} 门课程`);
+  console.log(`[MYSY] 成功导入 ${courses.length} 门课程`);
+
+  try {
+    if (window.shiguangBridge && typeof window.shiguangBridge.notifyTaskCompletion === 'function') {
+      window.shiguangBridge.notifyTaskCompletion();
+    }
+  } catch (error) {
+    console.warn('[MYSY] notifyTaskCompletion failed:', error);
+  }
+}
+
+if (/mtc\.edu\.cn$/i.test(window.location.hostname)) {
+  setTimeout(runImportFlow, 800);
+} else {
+  showToast('请先在绵阳师范学院教务系统打开课表页面');
+}

+ 118 - 132
resources/SCUEC/scuec.js

@@ -22,33 +22,44 @@ function parseWeeks(weekStr) {
     const weeks = [];
     if (!weekStr) return weeks;
 
-    weekStr = weekStr.trim();
-    const isSingleWeek = weekStr.includes('(单)');
-    const match = weekStr.match(/(\d+)\s*[-~]\s*(\d+)|(\d+)\s*周/);
-    
-    if (match) {
-        let start, end;
-        if (match[1] && match[2]) {
-            start = parseInt(match[1]);
-            end = parseInt(match[2]);
-        } else if (match[3]) {
-            start = end = parseInt(match[3]);
-        } else {
-            return weeks;
-        }
-        
-        if (isSingleWeek) {
-            for (let i = start; i <= end; i += 2) {
-                weeks.push(i);
-            }
-        } else {
-            for (let i = start; i <= end; i++) {
-                weeks.push(i);
-            }
+    const normalized = String(weekStr)
+        .replace(/周/g, '')
+        .replace(/\s+/g, '');
+
+    const parts = normalized.split(/[,,、;;]/).filter(Boolean);
+    for (const part of parts) {
+        const match = part.match(/^(\d+)(?:[-~](\d+))?(?:\((单|双)\))?$/);
+        if (!match) continue;
+
+        const start = Number(match[1]);
+        const end = match[2] ? Number(match[2]) : start;
+        const parity = match[3];
+
+        for (let i = start; i <= end; i++) {
+            if (parity === '单' && i % 2 === 0) continue;
+            if (parity === '双' && i % 2 === 1) continue;
+            weeks.push(i);
         }
     }
-    
-    return weeks;
+
+    return Array.from(new Set(weeks)).sort((a, b) => a - b);
+}
+
+/**
+ * 将 HTML 转成纯文本,不依赖被页面覆盖的 document.createElement。
+ */
+function htmlToText(html) {
+    if (!html) return '';
+
+    return String(html)
+        .replace(/<br\s*\/?>/gi, '\n')
+        .replace(/<[^>]+>/g, '')
+        .replace(/&nbsp;/gi, '\u00a0')
+        .replace(/&amp;/gi, '&')
+        .replace(/&lt;/gi, '<')
+        .replace(/&gt;/gi, '>')
+        .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(Number('0x' + hex)))
+        .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec)));
 }
 
 /**
@@ -57,21 +68,11 @@ function parseWeeks(weekStr) {
  */
 function cleanHTML(html) {
     if (!html) return '';
-    
-    // 创建临时元素
-    const temp = document.createElement('div');
-    temp.innerHTML = html;
-    
-    // 获取纯文本
-    let text = temp.textContent || temp.innerText || '';
-    
-    // 清理多余空格和特殊字符
-    text = text
-        .replace(/&nbsp;/g, ' ')      // 替换 nbsp
-        .replace(/\s+/g, ' ')         // 多个空格合并为一个
+
+    return htmlToText(html)
+        .replace(/\u00a0/g, ' ')
+        .replace(/\s+/g, ' ')
         .trim();
-    
-    return text;
 }
 
 /**
@@ -113,107 +114,67 @@ function parseSingleCourse(courseHTML) {
     if (!courseHTML || courseHTML.trim() === '') {
         return null;
     }
-    
+
     try {
-        // 用 <br> 分割成行
-        const lines = smartSplitLines(courseHTML, '<br');
-        
+        const lines = htmlToText(courseHTML)
+            .replace(/\u00a0/g, ' ')
+            .split(/\n+/)
+            .map((line) => line.replace(/\s+/g, ' ').trim())
+            .filter(Boolean);
+
         if (lines.length === 0) {
             return null;
         }
-        
-        console.log(`[DEBUG] 课程块行数: ${lines.length}`, lines);
-        
-        // ========== 第一行:课程名 + 周次 + 节次 ==========
+
         const firstLine = lines[0];
-        
-        // 提取课程名
-        let courseName = '';
-        const courseNameMatch = firstLine.match(/^(.+?)(?:\s*\[|\s+\d+-|\s*$)/);
-        if (courseNameMatch) {
-            courseName = courseNameMatch[1].trim();
-        }
-        
-        if (!courseName) {
-            console.warn('[WARN] 无法提取课程名:', firstLine);
-            return null;
-        }
-        
-        // 提取周次
-        const weekMatch = firstLine.match(/(\d+[-~]\d+周(?:\(单\))?|\d+周)/);
-        let weeks = [];
-        if (weekMatch) {
-            weeks = parseWeeks(weekMatch[1]);
-        }
-        
+        const weekMatch = firstLine.match(/\d+(?:\s*[-~]\s*\d+)?周(?:\((?:单|双)\))?/);
+        const sectionMatch = firstLine.match(/[((]第(\d+)(?:\s*[-~]\s*(\d+))?节[))]/);
+        const weekToken = weekMatch ? weekMatch[0] : '';
+        const weeks = parseWeeks(weekToken);
+
         if (weeks.length === 0) {
-            console.warn('[WARN] 无法提取周次:', firstLine);
             return null;
         }
-        
-        // 提取节次
-        let startSection = 0;
-        let endSection = 0;
-        const sectionRangeMatch = firstLine.match(/[((]第(\d+)[-~](\d+)节[))]/);
-        if (sectionRangeMatch) {
-            startSection = parseInt(sectionRangeMatch[1]);
-            endSection = parseInt(sectionRangeMatch[2]);
-        } else {
-            const singleSectionMatch = firstLine.match(/[((]第(\d+)节[))]/);
-            if (singleSectionMatch) {
-                startSection = endSection = parseInt(singleSectionMatch[1]);
-            }
-        }
-        
-        // ========== 后续行:教师和地点 ==========
-        let teacher = '';
-        let position = '';
-        
-        // 简单逻辑:第二行是教师,第三行是地点
-        if (lines.length > 1) {
-            const secondLine = lines[1];
-            // 检查是否是教师名(通常是汉字,且不包含"楼"等地点关键词)
-            if (secondLine && /[\u4e00-\u9fa5]/.test(secondLine) && !/[楼号室厅]/.test(secondLine)) {
-                teacher = secondLine;
-            } else if (secondLine && /[楼号室厅]/.test(secondLine)) {
-                // 第二行看起来是地点
-                position = secondLine;
-            } else {
-                // 其他情况作为教师
-                teacher = secondLine;
-            }
-        }
-        
-        if (lines.length > 2) {
-            const thirdLine = lines[2];
-            // 如果第三行看起来是地点,就作为地点
-            if (thirdLine && /[楼号室厅]/.test(thirdLine)) {
-                position = thirdLine;
-            } else if (thirdLine && !teacher) {
-                // 如果还没有教师,就作为教师
-                teacher = thirdLine;
-            } else if (thirdLine && !position) {
-                // 否则作为地点
-                position = thirdLine;
-            }
-        }
-        
-        // 如果还有第四行,作为地点
-        if (lines.length > 3 && !position) {
-            position = lines[3];
+
+        const weekIndex = weekMatch ? weekMatch.index : firstLine.length;
+        const sectionIndex = sectionMatch ? sectionMatch.index : firstLine.length;
+        const nameEnd = Math.min(weekIndex, sectionIndex);
+
+        let name = firstLine
+            .slice(0, nameEnd)
+            .replace(/\[\d+\]\s*$/, '')
+            .trim();
+
+        if (!name) {
+            return null;
         }
-        
-        console.log(`[DEBUG] 解析: 名="${courseName}", 师="${teacher}", 地="${position}", 周=${weeks.join(',')}, 节=${startSection}-${endSection}`);
-        
-        return {
-            name: courseName,
-            teacher: teacher || '',
-            position: position || '未指定',
+
+        let startSection = sectionMatch ? Number(sectionMatch[1]) : 0;
+        let endSection = sectionMatch && sectionMatch[2]
+            ? Number(sectionMatch[2])
+            : startSection;
+
+        const customTimeMatch = firstLine.match(/[((](\d{1,2}:\d{2})\s*[-~]\s*(\d{1,2}:\d{2})[))]/);
+        const rest = lines.slice(1);
+        const positionLine = rest.find((line) => /[楼馆场室厅]/.test(line));
+        const teacherLine = rest.find((line) => line !== positionLine);
+
+        const result = {
+            name: name,
+            teacher: teacherLine || rest.filter((line) => line !== positionLine).join(' '),
+            position: positionLine || '待定',
             startSection: startSection,
             endSection: endSection,
             weeks: weeks
         };
-        
+
+        if (customTimeMatch) {
+            result.isCustomTime = true;
+            result.customStartTime = customTimeMatch[1];
+            result.customEndTime = customTimeMatch[2];
+        }
+
+        return result;
     } catch (error) {
         console.error('[ERROR] 解析课程出错:', error);
         return null;
@@ -230,7 +191,7 @@ function extractCoursesFromCell(cellElement, dayIndex) {
         const cellHTML = cellElement.innerHTML || '';
         const cellText = cellElement.textContent || '';
         
-        if (!cellText || cellText.trim() === '' || cellText === '&nbsp;') {
+        if (!cellText || cellText.replace(/\u00a0/g, '').trim() === '') {
             return [];
         }
         
@@ -311,7 +272,7 @@ function extractCoursesFromTable() {
                 const sectionText = sectionCell.textContent.trim();
                 const sectionMatch = sectionText.match(/第(\d+)节/);
                 if (sectionMatch) {
-                    dayStartSection = parseInt(sectionMatch[1]);
+                    dayStartSection = Number(sectionMatch[1]);
                 }
             }
             
@@ -349,8 +310,8 @@ function extractCoursesFromTable() {
                     }
                 });
 
-                const rowspan = Math.max(parseInt(courseCell.getAttribute('rowspan') || '1', 10), 1);
-                const colspan = Math.max(parseInt(courseCell.getAttribute('colspan') || '1', 10), 1);
+                const rowspan = Math.max(Number(courseCell.getAttribute('rowspan') || '1'), 1);
+                const colspan = Math.max(Number(courseCell.getAttribute('colspan') || '1'), 1);
 
                 if (rowspan > 1) {
                     for (let offset = 0; offset < colspan && dayIndex + offset < dayColumns.length; offset++) {
@@ -433,7 +394,7 @@ function extractUnscheduledCourses(element) {
  * 生成时间段配置
  */
 function generateTimeSlots() {
-    return [
+    const fallback = [
         { "number": 1, "startTime": "08:00", "endTime": "08:45" },
         { "number": 2, "startTime": "08:55", "endTime": "09:40" },
         { "number": 3, "startTime": "10:00", "endTime": "10:45" },
@@ -446,6 +407,31 @@ function generateTimeSlots() {
         { "number": 10, "startTime": "19:30", "endTime": "20:15" },
         { "number": 11, "startTime": "20:20", "endTime": "21:05" }
     ];
+
+    const table = document.querySelector('table.CourseFormTable');
+    if (!table) return fallback;
+
+    const slots = [];
+    const rows = Array.from(table.rows);
+
+    for (const row of rows) {
+        const sectionCell = row.cells[1];
+        if (!sectionCell) continue;
+
+        const text = sectionCell.textContent.trim();
+        const numberMatch = text.match(/第(\d+)节/);
+        const timeMatch = text.match(/(\d{1,2}:\d{2})\s*~\s*(\d{1,2}:\d{2})/);
+
+        if (!numberMatch || !timeMatch) continue;
+
+        slots.push({
+            number: Number(numberMatch[1]),
+            startTime: timeMatch[1],
+            endTime: timeMatch[2]
+        });
+    }
+
+    return slots.length > 0 ? slots.sort((a, b) => a.number - b.number) : fallback;
 }
 
 // ========== 第二部分:业务函数 ==========