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

Merge pull request #479 from XingHeYuZhuan/pending

星河欲转 4 недель назад
Родитель
Сommit
c4410d50cd
3 измененных файлов с 625 добавлено и 113 удалено
  1. 330 71
      resources/CQUT/cqut_01.js
  2. 1 1
      resources/UJS/adapters.yaml
  3. 294 41
      resources/UJS/ujs_zhengfang_v9.0.js

+ 330 - 71
resources/CQUT/cqut_01.js

@@ -3,32 +3,59 @@
  * author: Dawn Drizzle
  * author: Dawn Drizzle
  */
  */
 const API_BASE = 'https://timetable-cfc.cqut.edu.cn/api/courseSchedule';
 const API_BASE = 'https://timetable-cfc.cqut.edu.cn/api/courseSchedule';
+const MAX_WEEK_REQUEST_CONCURRENCY = 5;
+const REQUEST_RETRY_COUNT = 1;
+const RETRY_DELAY_MS = 300;
+
+const wait = (duration) => new Promise((resolve) => setTimeout(resolve, duration));
 
 
 // 仅允许在课表站点内执行,避免跨站点误触发
 // 仅允许在课表站点内执行,避免跨站点误触发
 const checkLogin = () => window.location.hostname === 'timetable-cfc.cqut.edu.cn';
 const checkLogin = () => window.location.hostname === 'timetable-cfc.cqut.edu.cn';
 
 
-// 统一的接口请求封装:POST + JSON + 携带 Cookie,并在失败时提示原因
+// 统一的接口请求封装:POST + JSON + 携带 Cookie;网络错误、限流和服务端错误会重试一次
 const baseFetch = async (path, body, description) => {
 const baseFetch = async (path, body, description) => {
-    const response = await fetch(`${API_BASE}/${path}`, {
-        method: 'POST',
-        credentials: 'include',
-        headers: {
-            'Content-Type': 'application/json',
-        },
-        body: body === undefined ? undefined : JSON.stringify(body),
-    });
+    const requestBody = body === undefined ? undefined : JSON.stringify(body);
+
+    for (let attempt = 0; attempt <= REQUEST_RETRY_COUNT; attempt++) {
+        let response;
+
+        try {
+            response = await fetch(`${API_BASE}/${path}`, {
+                method: 'POST',
+                credentials: 'include',
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+                body: requestBody,
+            });
+        } catch (error) {
+            if (attempt < REQUEST_RETRY_COUNT) {
+                await wait(RETRY_DELAY_MS * (attempt + 1));
+                continue;
+            }
+
+            throw new Error(`获取${description}失败: ${error.message}`);
+        }
 
 
-    if (!response.ok) {
-        window.shiguangBridge.showToast(`获取${description}失败,请确认已登录后重试`);
-        throw new Error(`获取${description}失败: ${response.status} ${response.statusText}`);
-    }
+        if (!response.ok) {
+            const retryable = response.status === 429 || response.status >= 500;
 
 
-    try {
-        return await response.json();
-    } catch (error) {
-        window.shiguangBridge.showToast(`解析${description}失败,请确认当前页面登录状态`);
-        throw new Error(`解析${description}失败: ${error.message}`);
+            if (retryable && attempt < REQUEST_RETRY_COUNT) {
+                await wait(RETRY_DELAY_MS * (attempt + 1));
+                continue;
+            }
+
+            throw new Error(`获取${description}失败: ${response.status} ${response.statusText}`);
+        }
+
+        try {
+            return await response.json();
+        } catch (error) {
+            throw new Error(`解析${description}失败: ${error.message}`);
+        }
     }
     }
+
+    throw new Error(`获取${description}失败`);
 };
 };
 
 
 // 获取当前登录用户信息(包含 username、校区等)
 // 获取当前登录用户信息(包含 username、校区等)
@@ -48,15 +75,142 @@ const getWeekEvents = async (userID, weekNum, yearTerm, description) => await ba
     description,
     description,
 );
 );
 
 
-// 将接口的节次时间转换为统一结构,并按节次序号升序排序
-const parseTimeSlots = (timeSlots) => timeSlots
-    .slice()
-    .sort((left, right) => Number(left.sessionNum) - Number(right.sessionNum))
-    .map((timeSlot) => ({
-        number: Number(timeSlot.sessionNum) || 0,
-        startTime: timeSlot.startTime ?? '',
-        endTime: timeSlot.endTime ?? '',
-    }));
+// 使用固定数量的工作协程处理每周请求,避免短时间内向教务接口发出过多请求
+const mapWithConcurrency = async (items, concurrency, mapper) => {
+    const results = new Array(items.length);
+    let nextIndex = 0;
+    const workerCount = Math.min(concurrency, items.length);
+    const workers = Array.from({ length: workerCount }, async () => {
+        while (nextIndex < items.length) {
+            const currentIndex = nextIndex;
+            nextIndex += 1;
+            results[currentIndex] = await mapper(items[currentIndex], currentIndex);
+        }
+    });
+
+    await Promise.all(workers);
+    return results;
+};
+
+const normalizePositiveIntegerList = (values) => [...new Set(
+    (Array.isArray(values) ? values : [])
+        .map(Number)
+        .filter((value) => Number.isInteger(value) && value > 0)
+)].sort((left, right) => left - right);
+
+const normalizeWeekList = (weekList) => normalizePositiveIntegerList(weekList);
+
+// 检查接口响应中的学期是否与用户选择一致,防止服务端忽略 yearTerm 参数
+const assertMatchingYearTerm = (weekData, expectedYearTerm, description, required = false) => {
+    const actualYearTerm = String(weekData?.yearTerm ?? '').trim();
+
+    if (!actualYearTerm) {
+        if (required) {
+            throw new Error(`${description}缺少学期标识`);
+        }
+
+        return;
+    }
+
+    if (actualYearTerm !== expectedYearTerm) {
+        throw new Error(`${description}返回了错误学期: ${actualYearTerm}`);
+    }
+};
+
+// 将接口中的学期标识转换为更易读的选项文本
+const formatYearTerm = (yearTerm) => {
+    const value = String(yearTerm ?? '').trim();
+    const match = value.match(/^(\d{4})-(\d{4})-(\d+)$/);
+
+    return match ? `${match[1]}-${match[2]}学年 第${match[3]}学期` : value;
+};
+
+// 从当前周接口返回值中读取可用学期,并让用户选择要导入的学期
+const selectYearTerm = async (weekData) => {
+    const currentYearTerm = String(weekData?.yearTerm ?? '').trim();
+    const yearTermList = Array.isArray(weekData?.yearTermList)
+        ? weekData.yearTermList.map((item) => String(item ?? '').trim()).filter(Boolean)
+        : [];
+    const yearTerms = [...new Set(yearTermList)];
+
+    // 兼容接口暂未返回 yearTermList 的情况,仍允许导入当前学期
+    if (currentYearTerm && !yearTerms.includes(currentYearTerm)) {
+        yearTerms.unshift(currentYearTerm);
+    }
+
+    if (yearTerms.length === 0) {
+        throw new Error('学期列表为空');
+    }
+
+    const currentIndex = yearTerms.indexOf(currentYearTerm);
+    const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
+        '选择学期',
+        JSON.stringify(yearTerms.map(formatYearTerm)),
+        currentIndex >= 0 ? currentIndex : 0,
+    );
+
+    if (selectedIndex === null || Number(selectedIndex) === -1) {
+        window.shiguangBridge.showToast('已取消课程导入');
+        return null;
+    }
+
+    const normalizedIndex = Number(selectedIndex);
+
+    if (!Number.isInteger(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= yearTerms.length) {
+        throw new Error(`无效的学期选项索引: ${selectedIndex}`);
+    }
+
+    return yearTerms[normalizedIndex];
+};
+
+const normalizeTime = (value) => {
+    const match = String(value ?? '').trim().match(/^(\d{1,2}):(\d{2})$/);
+
+    if (!match) {
+        return null;
+    }
+
+    const hour = Number(match[1]);
+    const minute = Number(match[2]);
+
+    if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
+        return null;
+    }
+
+    return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
+};
+
+const timeToMinutes = (value) => {
+    const [hour, minute] = value.split(':').map(Number);
+    return hour * 60 + minute;
+};
+
+// 将接口的节次时间转换为统一结构,过滤无效项、按节次去重并升序排序
+const parseTimeSlots = (timeSlots) => {
+    const parsedTimeSlots = new Map();
+
+    for (const timeSlot of Array.isArray(timeSlots) ? timeSlots : []) {
+        const number = Number(timeSlot?.sessionNum);
+        const startTime = normalizeTime(timeSlot?.startTime);
+        const endTime = normalizeTime(timeSlot?.endTime);
+
+        if (
+            !Number.isInteger(number)
+            || number <= 0
+            || !startTime
+            || !endTime
+            || timeToMinutes(endTime) <= timeToMinutes(startTime)
+        ) {
+            continue;
+        }
+
+        if (!parsedTimeSlots.has(number)) {
+            parsedTimeSlots.set(number, { number, startTime, endTime });
+        }
+    }
+
+    return [...parsedTimeSlots.values()].sort((left, right) => left.number - right.number);
+};
 
 
 // 推算学期开始日期(YYYY-MM-DD):使用 weekDayList 第一条的月/日 + yearTerm 中的学年信息
 // 推算学期开始日期(YYYY-MM-DD):使用 weekDayList 第一条的月/日 + yearTerm 中的学年信息
 const parseSemesterStartDate = (yearTerm, weekDayList) => {
 const parseSemesterStartDate = (yearTerm, weekDayList) => {
@@ -66,22 +220,25 @@ const parseSemesterStartDate = (yearTerm, weekDayList) => {
         return null;
         return null;
     }
     }
 
 
-    const [startYear, endYear, termPart] = String(yearTerm).split('-');
-    const [month, day] = String(firstWeekDate).split('/').map(Number);
+    const yearTermMatch = String(yearTerm).match(/^(\d{4})-(\d{4})-([12])$/);
+    const weekDateMatch = String(firstWeekDate).trim().match(/^(\d{1,2})\/(\d{1,2})$/);
 
 
-    if (!startYear || !endYear || !termPart || Number.isNaN(month) || Number.isNaN(day)) {
+    if (!yearTermMatch || !weekDateMatch) {
         return null;
         return null;
     }
     }
 
 
+    const [, startYear, endYear, termPart] = yearTermMatch;
+    const month = Number(weekDateMatch[1]);
+    const day = Number(weekDateMatch[2]);
     const year = termPart === '1' ? Number(startYear) : Number(endYear);
     const year = termPart === '1' ? Number(startYear) : Number(endYear);
-
-    if (Number.isNaN(year)) {
-        return null;
-    }
-
     const date = new Date(Date.UTC(year, month - 1, day));
     const date = new Date(Date.UTC(year, month - 1, day));
 
 
-    if (Number.isNaN(date.getTime())) {
+    if (
+        Number.isNaN(date.getTime())
+        || date.getUTCFullYear() !== year
+        || date.getUTCMonth() !== month - 1
+        || date.getUTCDate() !== day
+    ) {
         return null;
         return null;
     }
     }
 
 
@@ -90,18 +247,44 @@ const parseSemesterStartDate = (yearTerm, weekDayList) => {
 
 
 // 将接口的 event 解析为课程结构(节次、星期、周次等)
 // 将接口的 event 解析为课程结构(节次、星期、周次等)
 const parseCourse = (event) => {
 const parseCourse = (event) => {
-    const sessionList = (event.sessionList ?? []).map(Number).filter((session) => !Number.isNaN(session));
-    const startSection = Number(event.sessionStart) || sessionList[0] || 0;
-    const endSection = sessionList[sessionList.length - 1] || startSection + (Number(event.sessionLast) || 1) - 1;
+    if (!event || typeof event !== 'object') {
+        return null;
+    }
+
+    const sessionList = normalizePositiveIntegerList(event.sessionList);
+    const declaredStartSection = Number(event.sessionStart);
+    const startSection = Number.isInteger(declaredStartSection) && declaredStartSection > 0
+        ? declaredStartSection
+        : sessionList[0];
+    const declaredDuration = Number(event.sessionLast);
+    const duration = Number.isInteger(declaredDuration) && declaredDuration > 0 ? declaredDuration : 1;
+    const endSection = sessionList[sessionList.length - 1] ?? startSection + duration - 1;
+    const name = String(event.eventName ?? '').trim();
+    const day = Number(event.weekDay);
+    const weeks = normalizeWeekList(event.weekList);
+
+    if (
+        !name
+        || !Number.isInteger(day)
+        || day < 1
+        || day > 7
+        || !Number.isInteger(startSection)
+        || startSection <= 0
+        || !Number.isInteger(endSection)
+        || endSection < startSection
+        || weeks.length === 0
+    ) {
+        return null;
+    }
 
 
     return {
     return {
-        name: event.eventName ?? '',
-        teacher: event.memberName ?? '',
-        position: event.address ?? '',
-        day: Number(event.weekDay) || 0,
+        name,
+        teacher: String(event.memberName ?? '').trim(),
+        position: String(event.address ?? '').trim(),
+        day,
         startSection,
         startSection,
         endSection,
         endSection,
-        weeks: (event.weekList ?? []).map(Number).filter((week) => !Number.isNaN(week)),
+        weeks,
     };
     };
 };
 };
 
 
@@ -111,6 +294,11 @@ const mergeCourses = (events) => {
 
 
     for (const event of events) {
     for (const event of events) {
         const course = parseCourse(event);
         const course = parseCourse(event);
+
+        if (!course) {
+            continue;
+        }
+
         const key = [
         const key = [
             course.name,
             course.name,
             course.teacher,
             course.teacher,
@@ -132,17 +320,37 @@ const mergeCourses = (events) => {
     return [...mergedCourses.values()];
     return [...mergedCourses.values()];
 };
 };
 
 
-// 将解析后的结构写入 App(配置、课程列表、节次时间表)
-const saveSchedule = (parsedSchedule) => Promise.all([
-    window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(parsedSchedule.courseConfig)),
-    window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedSchedule.courses)),
-    window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(parsedSchedule.timeSlots)),
-]);
+const saveBridgeData = async (description, saveAction) => {
+    try {
+        const result = await saveAction();
 
 
-// 主流程:校验页面 → 拉取用户/校区 → 拉取节次/当前学期信息 → 拉取全学期每周课程 → 合并保存
-(async () => {
+        if (result !== true) {
+            throw new Error(`返回值异常: ${String(result)}`);
+        }
+    } catch (error) {
+        throw new Error(`${description}保存失败: ${error?.message || String(error)}`);
+    }
+};
+
+// 顺序保存,并将课程放在最后,避免配置或作息保存失败时提前覆盖现有课程
+const saveSchedule = async (parsedSchedule) => {
+    await saveBridgeData(
+        '课表配置',
+        () => window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(parsedSchedule.courseConfig)),
+    );
+    await saveBridgeData(
+        '节次时间',
+        () => window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(parsedSchedule.timeSlots)),
+    );
+    await saveBridgeData(
+        '课程数据',
+        () => window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedSchedule.courses)),
+    );
+};
+
+// 主流程:校验页面 → 拉取用户/校区 → 获取并选择学期 → 拉取所选学期每周课程 → 合并保存
+const runImportFlow = async () => {
     if (!checkLogin()) {
     if (!checkLogin()) {
-        window.shiguangBridge.showToast('请先打开重庆理工大学课表页面并登录');
         throw new Error('当前不在重庆理工大学课表页面');
         throw new Error('当前不在重庆理工大学课表页面');
     }
     }
 
 
@@ -151,39 +359,90 @@ const saveSchedule = (parsedSchedule) => Promise.all([
     const campusName = userInfo?.userCustomSetting?.campusName;
     const campusName = userInfo?.userCustomSetting?.campusName;
 
 
     if (!userID || !campusName) {
     if (!userID || !campusName) {
-        window.shiguangBridge.showToast('未获取到完整的用户信息,请确认已登录');
         throw new Error('用户信息不完整');
         throw new Error('用户信息不完整');
     }
     }
 
 
-    const [timeSlotData, currentWeekData] = await Promise.all([
+    const [timeSlotData, semesterOverview] = await Promise.all([
         getCampusTimeInfo(campusName),
         getCampusTimeInfo(campusName),
-        getWeekEvents(userID, null, null, '当前学期信息'),
+        getWeekEvents(userID, null, null, '学期信息'),
     ]);
     ]);
 
 
-    const yearTerm = currentWeekData?.yearTerm;
-    const weekList = Array.isArray(currentWeekData?.weekList) ? currentWeekData.weekList : [];
-    const currentWeekNum = currentWeekData?.weekNum;
-    const semesterStartDate = parseSemesterStartDate(yearTerm, currentWeekData?.weekDayList);
+    const yearTerm = await selectYearTerm(semesterOverview);
+
+    if (yearTerm === null) {
+        return;
+    }
+
+    // 选择当前学期时复用首次请求;选择其他学期时重新获取其周次和日期信息
+    const selectedWeekData = String(semesterOverview?.yearTerm ?? '').trim() === yearTerm
+        ? semesterOverview
+        : await getWeekEvents(userID, null, yearTerm, `${formatYearTerm(yearTerm)}信息`);
+    assertMatchingYearTerm(selectedWeekData, yearTerm, '所选学期信息', true);
+    const weekList = normalizeWeekList(selectedWeekData?.weekList);
+    const timeSlots = parseTimeSlots(timeSlotData);
+
+    if (weekList.length === 0) {
+        throw new Error(`学期信息不完整: ${yearTerm}`);
+    }
+
+    if (timeSlots.length === 0) {
+        throw new Error('未获取到有效的节次时间');
+    }
 
 
-    if (!yearTerm || weekList.length === 0) {
-        window.shiguangBridge.showToast('未获取到当前学期信息');
-        throw new Error('当前学期信息不完整');
+    // semesterStartDate 必须取自第一周;首次接口可能返回的是当前周,不能直接使用其中的日期
+    const firstWeekNum = weekList[0];
+    const selectedWeekNum = Number(selectedWeekData?.weekNum);
+    const firstWeekData = selectedWeekNum === firstWeekNum
+        ? selectedWeekData
+        : await getWeekEvents(userID, String(firstWeekNum), yearTerm, `第${firstWeekNum}周课程`);
+    assertMatchingYearTerm(firstWeekData, yearTerm, `第${firstWeekNum}周课程`);
+    const semesterStartDate = parseSemesterStartDate(yearTerm, firstWeekData?.weekDayList);
+    const preloadedWeekData = new Map([[String(firstWeekNum), firstWeekData]]);
+
+    if (weekList.includes(selectedWeekNum)) {
+        preloadedWeekData.set(String(selectedWeekNum), selectedWeekData);
     }
     }
 
 
-    const weekResults = await Promise.all(
-        weekList.map((weekNum) => String(weekNum) === String(currentWeekNum)
-            ? currentWeekData
-            : getWeekEvents(userID, String(weekNum), yearTerm, `第${weekNum}周课程`))
+    const weekResults = await mapWithConcurrency(
+        weekList,
+        MAX_WEEK_REQUEST_CONCURRENCY,
+        (weekNum) => preloadedWeekData.get(String(weekNum))
+            ?? getWeekEvents(userID, String(weekNum), yearTerm, `第${weekNum}周课程`),
     );
     );
 
 
+    weekResults.forEach((result, index) => {
+        assertMatchingYearTerm(result, yearTerm, `第${weekList[index]}周课程`);
+    });
+
+    const events = weekResults.flatMap((result) => Array.isArray(result?.eventList) ? result.eventList : []);
+    const courses = mergeCourses(events);
+
+    if (courses.length === 0) {
+        window.shiguangBridge.showToast(`${formatYearTerm(yearTerm)}未找到课程,未修改现有课表`);
+        return;
+    }
+
     await saveSchedule({
     await saveSchedule({
         courseConfig: {
         courseConfig: {
             semesterStartDate,
             semesterStartDate,
-            semesterTotalWeeks: weekList.length,
+            semesterTotalWeeks: weekList[weekList.length - 1],
         },
         },
-        timeSlots: parseTimeSlots(timeSlotData),
-        courses: mergeCourses(weekResults.flatMap((result) => result?.eventList ?? [])),
+        timeSlots,
+        courses,
     });
     });
 
 
     window.shiguangBridge.notifyTaskCompletion();
     window.shiguangBridge.notifyTaskCompletion();
-})();
+};
+
+// 所有非用户取消类错误都在入口统一反馈,避免未处理的 Promise 拒绝
+const runImportFlowSafely = async () => {
+    try {
+        await runImportFlow();
+    } catch (error) {
+        const message = error?.message || String(error);
+        console.error(`[课程导入失败] ${message}`);
+        window.shiguangBridge.showToast(`课程导入失败:${message}`);
+    }
+};
+
+runImportFlowSafely();

+ 1 - 1
resources/UJS/adapters.yaml

@@ -5,4 +5,4 @@ adapters: #固定字段
     asset_js_path: "ujs_zhengfang_v9.0.js"
     asset_js_path: "ujs_zhengfang_v9.0.js"
     import_url: "https://jwxt.ujs.edu.cn/sso/jziotlogin"
     import_url: "https://jwxt.ujs.edu.cn/sso/jziotlogin"
     maintainer: "洛初 Github@gongfuture"
     maintainer: "洛初 Github@gongfuture"
-    description: "江苏大学教务系统适配,仅支持个人课表的导入,通过统一认证登录"
+    description: "江苏大学教务系统适配,仅支持个人课表的导入,通过统一认证登录  ver.2"

+ 294 - 41
resources/UJS/ujs_zhengfang_v9.0.js

@@ -5,6 +5,19 @@
 // 基于GSMC修改
 // 基于GSMC修改
 // 作者:洛初 Github@gongfuture
 // 作者:洛初 Github@gongfuture
 
 
+// 2026.03.30 第一版
+// 通过正方接口 xskbcx_cxXsgrkb 拉取个人课表,解析课程名、教师、教室、星期、节次和周次(含单双周)。
+// 交互上依次询问学年、学期与作息类型(夏令时/冬令时/智能选择,智能选择按固定切令时日期判定)。
+// 按课程所在楼栋匹配上午与下午作息,写入课程的自定义时间;导入课程、课表配置与预设时间段。
+
+// 2026.08.23 第二版
+// 桥接接口升级至 v2(window.shiguangBridge / window.shiguangBridgePromise)。
+// 补充开学日期:从首页日历区块取当前学期起止日期写入 semesterStartDate,总周数改为按实际周次取值;
+// 取不到开学日期时跳过配置保存,避免覆盖用户已有设置。
+// 课程备注补充重修标记、选课备注(体育项目、微专业等)与周次原文。
+// 集中实践课(军训、毕业设计等)无星期节次无法排课,改为弹窗提示手动添加。
+// 教师与教室为空不再丢弃课程;修正京江各号楼的楼栋匹配;支持校外 WebVPN 访问。
+
 /**
 /**
  * 解析周次字符串,处理单双周和周次范围。
  * 解析周次字符串,处理单双周和周次范围。
  */
  */
@@ -50,6 +63,62 @@ function parseWeeks(weekStr) {
     return [...new Set(weeks)].sort((a, b) => a - b);
     return [...new Set(weeks)].sort((a, b) => a - b);
 }
 }
 
 
+/**
+ * 拼接教务系统接口地址。
+ * 校内直连时 location.origin 就是教务域名,前缀为空;
+ * 校外经 WebVPN 访问时路径带有 /http/<hex> 前缀,必须保留,否则会变成跨域请求。
+ */
+function buildApiUrl(path) {
+    const prefixMatch = window.location.pathname.match(/^\/http\/[0-9a-f]+/i);
+    const prefix = prefixMatch ? prefixMatch[0] : "";
+    return window.location.origin + prefix + path;
+}
+
+/**
+ * 拼装课程备注。
+ * xm 只有姓名,kcmc 只有课程名,以下信息只存在于原始字段里,
+ * 放进备注方便用户核对:重修标记、选课备注(体育项目、微专业等)、周次原文。
+ */
+function buildCourseRemark(rawCourse) {
+    const parts = [];
+
+    const retakeFlag = String(rawCourse.cxbjmc || "").trim();
+    if (retakeFlag) {
+        parts.push(retakeFlag);
+    }
+
+    const selectionNote = String(rawCourse.xkbz || "").trim();
+    if (selectionNote) {
+        parts.push(selectionNote);
+    }
+
+    const weekDesc = String(rawCourse.zcd || "").trim();
+    if (weekDesc) {
+        parts.push(weekDesc);
+    }
+
+    return parts.join(" | ");
+}
+
+/**
+ * 解析集中实践课列表(sjkList)。
+ * 这类课程(军事技能训练、形势与政策等)只有课程名、教师和起止周,
+ * 没有星期和节次,无法映射到周课表,只能提示用户手动添加。
+ */
+function parsePracticeCourses(jsonData) {
+    if (!jsonData || !Array.isArray(jsonData.sjkList)) {
+        return [];
+    }
+
+    return jsonData.sjkList
+        .map((item) => ({
+            name: String(item.kcmc || "").trim(),
+            teacher: String(item.jsxm || "").trim(),
+            weekDesc: String(item.qsjsz || "").trim()
+        }))
+        .filter((item) => item.name);
+}
+
 /**
 /**
  * 解析 API 返回的 JSON 数据。
  * 解析 API 返回的 JSON 数据。
  */
  */
@@ -66,9 +135,10 @@ function parseJsonData(jsonData) {
     const finalCourseList = [];
     const finalCourseList = [];
 
 
     for (const rawCourse of rawCourseList) {
     for (const rawCourse of rawCourseList) {
-        // 关键字段检查: kcmc(课名), xm(教师), cdmc(教室), xqj(星期), jcs(节次范围), zcd(周次描述)
-        if (!rawCourse.kcmc || !rawCourse.xm || !rawCourse.cdmc ||
-            !rawCourse.xqj || !rawCourse.jcs || !rawCourse.zcd) {
+        // 关键字段检查:只有 kcmc(课名), xqj(星期), jcs(节次范围), zcd(周次描述) 是排课必需的。
+        // xm(教师) 与 cdmc(教室) 在实践课、线上课、未排地点的课程上可能为空,
+        // 缺这两项不影响排课,不能因此丢弃整门课程。
+        if (!rawCourse.kcmc || !rawCourse.xqj || !rawCourse.jcs || !rawCourse.zcd) {
             continue;
             continue;
         }
         }
 
 
@@ -92,15 +162,23 @@ function parseJsonData(jsonData) {
             continue;
             continue;
         }
         }
 
 
-        finalCourseList.push({
-            name: rawCourse.kcmc.trim(),
-            teacher: rawCourse.xm.trim(),
-            position: rawCourse.cdmc.trim(),
+        const remark = buildCourseRemark(rawCourse);
+
+        const course = {
+            name: String(rawCourse.kcmc).trim(),
+            teacher: String(rawCourse.xm || "").trim(),
+            position: String(rawCourse.cdmc || "").trim(),
             day: day,
             day: day,
             startSection: startSection,
             startSection: startSection,
             endSection: endSection,
             endSection: endSection,
             weeks: weeksArray
             weeks: weeksArray
-        });
+        };
+
+        if (remark) {
+            course.remark = remark;
+        }
+
+        finalCourseList.push(course);
     }
     }
 
 
     finalCourseList.sort((a, b) =>
     finalCourseList.sort((a, b) =>
@@ -177,7 +255,7 @@ async function whetherSummerTimeSlot() {
 
 
     // } catch (error) {
     // } catch (error) {
     //     console.error('JS: 获取作息时间公告失败:', error);
     //     console.error('JS: 获取作息时间公告失败:', error);
-    //     AndroidBridge.showToast("无法获取作息时间公告,智能选择回退到预设时间。");
+    //     window.shiguangBridge.showToast("无法获取作息时间公告,智能选择回退到预设时间。");
 
 
     //     // 预设日期
     //     // 预设日期
     //     const summerStart = new Date(new Date().getFullYear(), 3, 7); // 4月7日
     //     const summerStart = new Date(new Date().getFullYear(), 3, 7); // 4月7日
@@ -192,10 +270,12 @@ async function whetherSummerTimeSlot() {
     // }
     // }
 
 
     // CORS 问题导致无法获取公告页,智能选择回退到预设时间。
     // CORS 问题导致无法获取公告页,智能选择回退到预设时间。
+    // 冬令时是十一假期结束后调整,取 10 月 7 日;夏令时公告历年均为 4 月 7 日起执行。
+    // 参考教务处历年作息时间表公告:https://jwc.ujs.edu.cn/index/xl_zuo_xi_shi_jian.htm
 
 
     // 预设日期
     // 预设日期
     const summerStart = new Date(new Date().getFullYear(), 3, 7); // 4月7日
     const summerStart = new Date(new Date().getFullYear(), 3, 7); // 4月7日
-    const winterStart = new Date(new Date().getFullYear(), 9, 8); // 10月8
+    const winterStart = new Date(new Date().getFullYear(), 9, 7); // 10月7
 
 
     const now = new Date();
     const now = new Date();
     if (now >= summerStart && now < winterStart) {
     if (now >= summerStart && now < winterStart) {
@@ -205,16 +285,40 @@ async function whetherSummerTimeSlot() {
     }
     }
 }
 }
 
 
+/**
+ * 计算本次导入的作息何时失效。
+ * 作息时间是导入时一次性写死的,不会自动跟随切令时变化,
+ * 所以这里返回下一个「切到另一种令时」的日期,用于提示用户届时重新导入。
+ * 夏令时 4 月 7 日起执行,冬令时十一假期后(10 月 7 日)起执行。
+ */
+function getNextTimeSlotSwitchDate(isSummerTime) {
+    const now = new Date();
+    const year = now.getFullYear();
+    const targetLabel = isSummerTime ? "冬令时" : "夏令时";
+
+    const candidates = [
+        { date: new Date(year, 3, 7), label: "夏令时" },
+        { date: new Date(year, 9, 7), label: "冬令时" },
+        { date: new Date(year + 1, 3, 7), label: "夏令时" },
+        { date: new Date(year + 1, 9, 7), label: "冬令时" }
+    ];
+
+    const next = candidates.find((item) => item.date > now && item.label === targetLabel);
+
+    return {
+        label: next.label,
+        text: `${next.date.getFullYear()}年${next.date.getMonth() + 1}月${next.date.getDate()}日`
+    };
+}
+
 /**
 /**
  * 检查是否在登录页面。
  * 检查是否在登录页面。
- * 只有当 URL 精确匹配时,才返回 true。
+ * 校内直连时地址是 http://jwxt.ujs.edu.cn/sso/jziotlogin,
+ * 校外经 WebVPN 时地址是 https://webvpn.ujs.edu.cn/http/<hex>/sso/jziotlogin,
+ * 因此按路径结尾判断,两种入口都能识别。
  */
  */
 function isLoginPage() {
 function isLoginPage() {
-    const url = window.location.href;
-    const loginUrl = "http://jwxt.ujs.edu.cn/sso/jziotlogin";
-
-    // 如果当前 URL 与指定的登录 URL 完全一致,则返回 true (是登录页)
-    return url === loginUrl;
+    return window.location.pathname.endsWith("/sso/jziotlogin");
 }
 }
 
 
 
 
@@ -278,7 +382,7 @@ async function selectTimeSlot() {
 
 
 async function reselectTimeSlot(selectedTimeSlot) {
 async function reselectTimeSlot(selectedTimeSlot) {
     const options = ["对的对的,就是这个", "不对不对,应该是另外一个"];
     const options = ["对的对的,就是这个", "不对不对,应该是另外一个"];
-    const dialogTitle = "当前智能选择结果为: " + (selectedTimeSlot ? "夏令时" : "冬令时") + "是否更改选择?";
+    const dialogTitle = "当前智能选择结果为: \n  " + (selectedTimeSlot ? "夏令时" : "冬令时") + "\n是否更改选择?";
     const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
     const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
         dialogTitle,
         dialogTitle,
         JSON.stringify(options),
         JSON.stringify(options),
@@ -302,6 +406,86 @@ function getSemesterCode(semesterIndex) {
 }
 }
 
 
 
 
+/**
+ * 获取教务系统当前学期的起止日期。
+ * 首页日历区块的标题形如 "2026-2027学年1学期(2026-08-31至2027-02-21)",
+ * 其中起始日期就是第 1 周周一,正是 semesterStartDate 需要的值。
+ * 注意:该接口忽略 xnm/xqm 参数,只返回当前学期,
+ * 因此只有用户选择的学年学期与返回值一致时才能使用。
+ */
+async function fetchCurrentSemesterRange() {
+    const url = buildApiUrl("/xtgl/index_cxAreaFive.html?localeKey=zh_CN&gnmkdm=index");
+
+    try {
+        const response = await fetch(url, {
+            "headers": {
+                "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
+            },
+            "body": "",
+            "method": "POST",
+            "credentials": "include"
+        });
+
+        if (!response.ok) {
+            throw new Error(`状态码 ${response.status}`);
+        }
+
+        const html = await response.text();
+        const match = html.match(/(\d{4})-\d{4}学年(\d)学期\s*[((](\d{4}-\d{2}-\d{2})至(\d{4}-\d{2}-\d{2})[))]/);
+
+        if (!match) {
+            console.warn("JS: 未能从日历区块解析出学期起止日期。");
+            return null;
+        }
+
+        const range = {
+            academicYear: match[1],
+            semesterIndex: Number(match[2]) - 1,
+            startDate: match[3],
+            endDate: match[4]
+        };
+        console.log("JS: 教务系统当前学期:", range);
+        return range;
+
+    } catch (error) {
+        console.warn("JS: 获取学期起止日期失败:", error);
+        return null;
+    }
+}
+
+/**
+ * 计算课表配置。
+ *
+ * 应用侧的 saveCourseConfig 是整体覆盖而非字段级合并:没有传入的字段会被写成模型默认值,
+ * 其中 semesterStartDate 的默认值是 null,会把用户已经设置好的开学日期清空。
+ * 所以拿不到真实开学日期时返回 null,由调用方跳过整个配置保存,宁可不写也不要写坏。
+ *
+ * defaultClassDuration / defaultBreakDuration 不显式传入,会被重置为应用默认的 45 / 10 分钟,
+ * 与江大「45 分钟一节、课间 10 分钟」一致,因此没有副作用。
+ */
+function buildCourseConfig(courses, semesterRange, firstDayOfWeek) {
+    if (!semesterRange) {
+        return null;
+    }
+
+    let maxWeek = 0;
+    for (const course of courses) {
+        for (const week of course.weeks) {
+            if (week > maxWeek) {
+                maxWeek = week;
+            }
+        }
+    }
+
+    return {
+        semesterStartDate: semesterRange.startDate,
+        // 只增不减:默认 20 周,课表里出现更大的周次时才扩展。
+        semesterTotalWeeks: Math.max(maxWeek, 20),
+        firstDayOfWeek: firstDayOfWeek
+    };
+}
+
+
 /**
 /**
  * 请求和解析课程数据
  * 请求和解析课程数据
  */
  */
@@ -312,7 +496,7 @@ async function fetchAndParseCourses(academicYear, semesterIndex) {
 
 
     // API URL 和请求体
     // API URL 和请求体
     const xnmXqmBody = `xnm=${academicYear}&xqm=${semesterCode}&kzlx=ck&xsdm=&kclbdm=`;
     const xnmXqmBody = `xnm=${academicYear}&xqm=${semesterCode}&kzlx=ck&xsdm=&kclbdm=`;
-    const url = "http://jwxt.ujs.edu.cn/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151";
+    const url = buildApiUrl("/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151");
 
 
     console.log(`JS: 发送请求到 ${url}, body: ${xnmXqmBody}`);
     console.log(`JS: 发送请求到 ${url}, body: ${xnmXqmBody}`);
 
 
@@ -353,14 +537,21 @@ async function fetchAndParseCourses(academicYear, semesterIndex) {
 
 
         console.log("JS: 课程列表预览:", courses.slice(0, 5)); // 预览前5门课程
         console.log("JS: 课程列表预览:", courses.slice(0, 5)); // 预览前5门课程
 
 
-        // 默认总周数为 20,周一为一周第一天
-        const config = {
-            semesterTotalWeeks: 20,
-            firstDayOfWeek: 1
-        };
+        // 集中实践课(军训、形势与政策等)没有星期和节次,无法排进周课表,单独取出用于提示
+        const practiceCourses = parsePracticeCourses(jsonData);
+        if (practiceCourses.length > 0) {
+            console.log(`JS: 检测到 ${practiceCourses.length} 门集中实践课,无法自动导入。`);
+        }
 
 
-        // 返回课程列表和配置信息
-        return { courses: courses, config: config };
+        // qsxqj: 教务系统设置的一周起始星期几,缺失时按周一处理。
+        const rawFirstDay = Number(jsonData.qsxqj);
+        const firstDayOfWeek = (rawFirstDay >= 1 && rawFirstDay <= 7) ? rawFirstDay : 1;
+
+        return {
+            courses: courses,
+            practiceCourses: practiceCourses,
+            firstDayOfWeek: firstDayOfWeek
+        };
 
 
     } catch (error) {
     } catch (error) {
         window.shiguangBridge.showToast(`请求或解析失败: ${error.message}`);
         window.shiguangBridge.showToast(`请求或解析失败: ${error.message}`);
@@ -383,6 +574,48 @@ async function saveCourses(parsedCourses) {
     }
     }
 }
 }
 
 
+/**
+ * 只在能拿到真实开学日期时写入课表配置。
+ * 拿不到就完全不调用 saveCourseConfig —— 应用侧是整体覆盖,
+ * 传入不含 semesterStartDate 的配置会把用户已设置的开学日期清空。
+ */
+async function saveCourseConfigIfPossible(courses, academicYear, semesterIndex, firstDayOfWeek) {
+    const semesterRange = await fetchCurrentSemesterRange();
+
+    let usableRange = null;
+    if (semesterRange) {
+        const sameYear = semesterRange.academicYear === String(academicYear);
+        const sameSemester = semesterRange.semesterIndex === semesterIndex;
+
+        if (sameYear && sameSemester) {
+            usableRange = semesterRange;
+        } else {
+            console.log(
+                `JS: 所选学年学期(${academicYear}/第${semesterIndex + 1}学期)` +
+                `不是教务系统当前学期(${semesterRange.academicYear}/第${semesterRange.semesterIndex + 1}学期),跳过开学日期写入。`
+            );
+        }
+    }
+
+    const config = buildCourseConfig(courses, usableRange, firstDayOfWeek);
+
+    if (!config) {
+        window.shiguangBridge.showToast("未取到本学期开学日期,已跳过课表配置,请在应用内手动设置开学日期。");
+        console.log("JS: 无可用开学日期,跳过 saveCourseConfig 以保留用户现有配置。");
+        return;
+    }
+
+    try {
+        await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+        window.shiguangBridge.showToast(
+            `课表配置更新成功!开学日期 ${config.semesterStartDate},总周数 ${config.semesterTotalWeeks} 周。`
+        );
+    } catch (error) {
+        window.shiguangBridge.showToast(`课表配置保存失败: ${error.message}`);
+        console.error('JS: Save Config Error:', error);
+    }
+}
+
 // 上午作息时间
 // 上午作息时间
 // 北固及本部主楼、主A楼、生环楼、汽车能动楼、京江楼
 // 北固及本部主楼、主A楼、生环楼、汽车能动楼、京江楼
 const AMorningTimeSlots = [
 const AMorningTimeSlots = [
@@ -488,7 +721,9 @@ function getCampusTypeFromPosition(position) {
 function getMorningTypeFromPosition(position) {
 function getMorningTypeFromPosition(position) {
     const text = String(position || "").trim();
     const text = String(position || "").trim();
 
 
-    if (text.includes("主A楼") || text.includes("京江楼")) return "A";
+    // 教务系统返回的是「京江2号楼2101」「京江3号楼3407」这类名称,没有「京江楼」这个写法,
+    // 所以这里匹配「京江」而不是「京江楼」。
+    if (text.includes("主A楼") || text.includes("京江")) return "A";
     if (text.includes("三江楼")) return "B";
     if (text.includes("三江楼")) return "B";
     if (text.includes("三山楼") || text.includes("讲堂群")) return "C";
     if (text.includes("三山楼") || text.includes("讲堂群")) return "C";
 
 
@@ -642,33 +877,51 @@ async function runImportFlow() {
         console.log("JS: 课程获取或解析失败,流程终止。");
         console.log("JS: 课程获取或解析失败,流程终止。");
         return;
         return;
     }
     }
-    const { courses, config } = result;
+    const { courses, practiceCourses, firstDayOfWeek } = result;
 
 
     const coursesWithCustomTime = applyCustomTimeToCourses(courses, isSummerTime);
     const coursesWithCustomTime = applyCustomTimeToCourses(courses, isSummerTime);
 
 
-    // 只匹配了主A楼、京江楼、三江楼、三山楼、讲堂群这几个教学楼的作息时间,其他课程都使用默认时间
-    const timeSlotAlert = await window.shiguangBridgePromise.showAlert(
-        "楼栋自定义时间提示",
-        "脚本已根据课程所在位置智能匹配了作息时间,部分课程可能与预设时间不符。\n" +
-        "请在课表页面核对课程时间,如有错误请手动修改课程所在位置或节次信息。\n" +
-        "(目前仅主A楼、京江楼、三江楼、三山楼、讲堂群的课程会应用自定义时间,其他课程默认时间不变)\n" +
+    // 作息时间在导入时一次性写入,不会自动跟随切令时变化,需要明确告知用户。
+    // 同时说明只有部分教学楼收录了独立作息,其余楼栋使用默认作息时间。
+    const nextSwitch = getNextTimeSlotSwitchDate(isSummerTime);
+    await window.shiguangBridgePromise.showAlert(
+        "作息时间提示",
+        `本次按${isSummerTime ? "夏令时" : "冬令时"}导入。作息时间在导入时写入,不会自动跟随学校切换令时。\n` +
+        `${nextSwitch.text}起学校切换为${nextSwitch.label},届时请重新导入课表,或在应用内手动修改时间段。\n\n` +
+        "脚本已根据课程所在位置匹配作息时间,部分课程可能与预设时间不符。\n" +
+        "请在课表页面核对课程时间,如有错误请手动修改课程所在位置或节次信息。\n\n" +
+        "已收录独立作息的楼栋:主A楼、京江各号楼、三江楼、三山楼、讲堂群。\n" +
+        "其余楼栋(各学院楼、各实验室、运动场、未排地点等)使用默认作息时间。\n\n" +
         "欢迎其他楼栋的同学提供课程时间信息以完善脚本!",
         "欢迎其他楼栋的同学提供课程时间信息以完善脚本!",
         "我知道了"
         "我知道了"
     );
     );
 
 
+    if (practiceCourses.length > 0) {
+        const practiceList = practiceCourses
+            .map((item) => {
+                const teacher = item.teacher ? `(${item.teacher})` : "";
+                const weekDesc = item.weekDesc ? ` ${item.weekDesc}` : "";
+                return `· ${item.name}${teacher}${weekDesc}`;
+            })
+            .join("\n");
+
+        console.log("JS: 集中实践课列表:", practiceCourses);
+        await window.shiguangBridgePromise.showAlert(
+            "集中实践课需手动添加",
+            `本学期有 ${practiceCourses.length} 门集中实践课,教务系统未给出星期和节次,无法自动导入:\n\n` +
+            practiceList +
+            "\n\n请按实际安排在应用内手动添加。",
+            "我知道了"
+        );
+    }
+
     const saveResult = await saveCourses(coursesWithCustomTime);
     const saveResult = await saveCourses(coursesWithCustomTime);
     if (!saveResult) {
     if (!saveResult) {
         console.log("JS: 课程保存失败,流程终止。");
         console.log("JS: 课程保存失败,流程终止。");
         return;
         return;
     }
     }
 
 
-    try {
-        await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
-        window.shiguangBridge.showToast(`课表配置更新成功!总周数:${config.semesterTotalWeeks}周。`);
-    } catch (error) {
-        window.shiguangBridge.showToast(`课表配置保存失败: ${error.message}`);
-        console.error('JS: Save Config Error:', error);
-    }
+    await saveCourseConfigIfPossible(coursesWithCustomTime, academicYear, semesterIndex, firstDayOfWeek);
 
 
     await importPresetTimeSlots(isSummerTime ? SummerTimeSlots : WinterTimeSlots);
     await importPresetTimeSlots(isSummerTime ? SummerTimeSlots : WinterTimeSlots);
 
 
@@ -678,4 +931,4 @@ async function runImportFlow() {
     window.shiguangBridge.notifyTaskCompletion();
     window.shiguangBridge.notifyTaskCompletion();
 }
 }
 
 
-runImportFlow();
+runImportFlow();