Prechádzať zdrojové kódy

Merge pull request #809 from ShiGuangSchedule/pending

星河欲转 4 týždňov pred
rodič
commit
e8bfd31ffa

+ 5 - 0
index/root_index.yaml

@@ -1207,3 +1207,8 @@ schools:
     name: "肇庆学院"
     initial: "Z"
     resource_folder: "ZQU"
+
+  - id: "HZU"
+    name: "菏泽学院"
+    initial: "H"
+    resource_folder: "HZU"

+ 1 - 1
resources/GUC/adapters.yaml

@@ -3,7 +3,7 @@ adapters:
   - adapter_id: "GUC"
     adapter_name: "吉利学院"
     category: "BACHELOR_AND_ASSOCIATE"
-    asset_js_path: "school.js"
+    asset_js_path: "guc.js"
     import_url: "https://jw.guc.edu.cn/yethan/UserAction?setAction=userLogin"
     maintainer: "Selffox"
     description: "吉利学院(Geely University of China)YETHAN以专教务系统,登录含图片验证码,需在页面内手动登录后导入"

+ 148 - 60
resources/GUC/school.js → resources/GUC/guc.js

@@ -12,14 +12,14 @@
 // 4. 课表视图分裂:全部周次视图表头无日期,仅按周(weekNo=N)视图表头带日期
 // 5. 学生年级/学历等字段经接口加密下发、无干净字段(故无法按学号可靠推算"大几",只显示真实学年学期)
 // 6. 功能集中在单 Action(CourseAction?setAction=...)按字符串分发,无 REST
+// 7. 课表页"全部周次"下拉纯纯摆设:提交空值(weekNo=)照样只给"当前周",实测单次只拿到 2 门课——
+//    只好逐周请求十几次再自己合并(详见 fetchAllWeeksTimetable 注释)
 
 
 // ==================== 常量 ====================
 
-// 课表页面地址(selectTableType: ThisTerm=本学期, NextTerm=下学期)
-const COURSE_TABLE_URL = "https://jw.guc.edu.cn/yethan/CourseAction?setAction=userCourseScheduleTable&viewType=studentCourseTableWeek&selectTableType=";
-
-// 课表表单提交地址("按周次查课表"用 POST 提交,weekNo=1 请求用于解析开学日期,复刻页面表单)
+// 课表接口地址(所有课表请求均走 POST 表单提交,复刻页面表单:
+// setAction=userCourseScheduleTable / viewType=studentCourseTableWeek / selectTableType=<学期> / queryType=student / weekNo=<周次,空=全部>)
 const COURSE_ACTION_URL = "https://jw.guc.edu.cn/yethan/CourseAction";
 
 // 默认导入学期(用户在选择学期弹窗中取消时回退到本学期)
@@ -265,6 +265,29 @@ function parseTimetableToCourses(html) {
     return mergeAndDistinctCourses(results);
 }
 
+/**
+ * 校验作息是否满足官方规则(wiki《一些数据的格式信息》1.2 节):
+ *   - number 必须从 1 开始连续递增,不允许空洞;
+ *   - startTime / endTime 匹配 "HH:mm" 且 start < end;
+ *   - 相邻节次不重叠(下一节 startTime 不早于上一节 endTime)。
+ * 返回 false 时调用方应回退写死常量,避免 App 侧校验拒绝导致作息丢失。
+ */
+function isValidTimeSlots(slots) {
+    if (!Array.isArray(slots) || slots.length === 0) return false;
+    const toMinutes = t => {
+        const m = /^(\d{1,2}):(\d{2})$/.exec(t || "");
+        return m ? parseInt(m[1], 10) * 60 + parseInt(m[2], 10) : NaN;
+    };
+    for (let i = 0; i < slots.length; i++) {
+        if (slots[i].number !== i + 1) return false;            // 必须从 1 连续递增
+        const s = toMinutes(slots[i].startTime);
+        const e = toMinutes(slots[i].endTime);
+        if (isNaN(s) || isNaN(e) || s >= e) return false;        // 格式合法 且 start < end
+        if (i > 0 && s < toMinutes(slots[i - 1].endTime)) return false; // 相邻不重叠
+    }
+    return true;
+}
+
 /**
  * 从课表 HTML 动态提取作息时间(节次 + 上课时间列,如 "08:20-09:05")。
  * 返回 [{ number, startTime, endTime }];无有效节次时返回 null(由调用方回退写死常量)。
@@ -287,7 +310,8 @@ function parseTimeSlots(html) {
         }
         if (slots.length === 0) return null;
         slots.sort((a, b) => a.number - b.number);
-        return slots;
+        // 官方校验不通过则返回 null → 调用方回退写死 TIME_SLOTS
+        return isValidTimeSlots(slots) ? slots : null;
     } catch (e) {
         console.error("解析作息时间失败:", e);
         return null;
@@ -311,34 +335,10 @@ async function fetchWithTimeout(url, options) {
 }
 
 /**
- * 拉取本学期课表页面并检测登录状态(一次请求完成)。
- * 返回:
- *   - { loggedIn: true, html }  成功
- *   - { error: "not_logged_in" }   未登录(页面含验证码输入框/登录表单)
- *   - { error: "network" }         网络异常或超时
- *   - { error: "request_failed" }  HTTP 请求失败(非 2xx)
- *   - { error: "no_table" }        已登录但页面无课表
+ * 请求指定周次的课表(POST,复刻页面"查周课表"表单)。
+ * 成功返回响应 HTML;任何失败返回 null。
  */
-async function fetchTimetableOnce(semesterType) {
-    try {
-        const resp = await fetchWithTimeout(COURSE_TABLE_URL + semesterType + "&queryType=student");
-        if (!resp.ok) return { error: "request_failed" };
-        const html = await resp.text();
-        if (html.includes('id="ranstring"') || html.includes("LoginForm")) return { error: "not_logged_in" };
-        if (!html.includes("table_border")) return { error: "no_table" };
-        return { loggedIn: true, html };
-    } catch (e) {
-        console.error("获取课表失败:", e);
-        return { error: "network" };
-    }
-}
-
-/**
- * 请求第 1 周课表(POST,复刻页面"查周课表"表单,weekNo=1)。
- * 第 1 周视图的表头带日期(如 "星期一<br>03月02日"),用于解析开学日期。
- * 成功返回响应 HTML;任何失败返回 null(不阻断主流程,开学日期缺失时 App 端可手动设置)。
- */
-async function fetchWeek1Timetable(semesterType) {
+async function fetchWeekTimetable(semesterType, weekNo) {
     try {
         const resp = await fetchWithTimeout(COURSE_ACTION_URL, {
             method: "POST",
@@ -348,17 +348,62 @@ async function fetchWeek1Timetable(semesterType) {
                 viewType: "studentCourseTableWeek",
                 selectTableType: semesterType,
                 queryType: "student",
-                weekNo: "1"
+                weekNo: String(weekNo)
             }).toString()
         });
         if (!resp.ok) return null;
         return await resp.text();
     } catch (e) {
-        console.error("获取第1周课表失败:", e);
+        console.error(`获取第${weekNo}周课表失败:`, e);
         return null;
     }
 }
 
+/**
+ * 逐周抓取整学期课表并合并(教务没有可用的"全部周次"接口)。
+ *
+ * ⚠️ 实测结论:页面下拉的"全部周次"(weekNo 为空)实际仍返回"当前周"课表,
+ * 教务默认视图也是"当前周"——只请求一次会漏掉大量课程(实测仅得到 2 门)。
+ * 因此必须逐周请求:每周响应中的课程自带完整周次信息,合并去重即可还原整学期。
+ * 策略:先抓第 1 周(同时用于登录检测与解析开学日期),再按第 1 周课程的最大周次
+ * 并发补齐其余周次(并发 4,兼顾速度与教务限流)。
+ *
+ * 返回:
+ *   - { loggedIn: true, courses, week1Html }  成功(week1Html 供解析开学日期)
+ *   - { error: "not_logged_in" | "network" | "no_table" }
+ */
+async function fetchAllWeeksTimetable(semesterType, onProgress) {
+    const firstHtml = await fetchWeekTimetable(semesterType, 1);
+    if (!firstHtml) return { error: "network" };
+    if (firstHtml.includes('id="ranstring"') || firstHtml.includes("LoginForm")) return { error: "not_logged_in" };
+    if (!firstHtml.includes("table_border")) return { error: "no_table" };
+
+    const week1Courses = parseTimetableToCourses(firstHtml);
+    const maxWeek = week1Courses.reduce((m, c) => Math.max(m, Math.max(...c.weeks)), 0) || 19;
+    const all = [...week1Courses];
+
+    const weeks = [];
+    for (let w = 2; w <= Math.min(maxWeek, 25); w++) weeks.push(w);
+
+    const CHUNK = 4;
+    let processed = 1;
+    const total = weeks.length + 1;
+    if (onProgress) onProgress(processed, total);
+    for (let i = 0; i < weeks.length; i += CHUNK) {
+        const batch = weeks.slice(i, i + CHUNK);
+        const htmls = await Promise.all(batch.map(w => fetchWeekTimetable(semesterType, w)));
+        htmls.forEach(h => {
+            if (!h) return;
+            const cs = parseTimetableToCourses(h);
+            if (cs.length) all.push(...cs);
+        });
+        processed += batch.length;
+        if (onProgress) onProgress(Math.min(processed, total), total);
+    }
+
+    return { loggedIn: true, courses: mergeAndDistinctCourses(all), week1Html: firstHtml };
+}
+
 /**
  * 从第 1 周课表 HTML 解析开学日期(第一周第一天 = 表头"星期一"的日期)。
  * 返回 "yyyy-MM-dd"(官方 CourseConfigJsonModel 格式);解析失败返回 null。
@@ -430,16 +475,41 @@ function parseSemesterLabel(html) {
 }
 
 /**
- * 探测指定学期的真实学期名。请求选课页(userCourseSchedule)拿页头学期标识;
- * 任何失败返回 null(调用方回退用通用名"本学期/下学期")。
+ * 从选课页 HTML 解析"课程名称 → 学分"映射。
+ * 表格列:序号|学期|选课编号|课程代码|课程名称|班号|开课学院|任课教师|学分|性质|上课时间地点|操作
+ * 表头行、未选课占位行(课程名为空)、学分非正数均自动跳过。解析失败返回空对象。
  */
-async function fetchSemesterLabel(type) {
+function parseCreditMap(html) {
+    const map = {};
+    try {
+        const rows = getTableRows(html);
+        for (const row of rows) {
+            const cells = Array.from(row.querySelectorAll("td"));
+            if (cells.length < 9) continue;
+            const name = (cells[4].textContent || "").trim();      // 课程名称
+            if (!name) continue;
+            const credit = parseFloat((cells[8].textContent || "").trim()); // 学分
+            if (!isNaN(credit) && credit > 0) map[name] = credit;
+        }
+    } catch (e) {
+        console.error("解析学分失败:", e);
+    }
+    return map;
+}
+
+/**
+ * 探测指定学期:请求选课页(userCourseSchedule)一次,同时得到
+ *   ① 页头真实学期名(如 "2025-2026第2学期")② 课程名称→学分映射。
+ * 任何失败返回 null(调用方回退通用名,且不注入学分)。
+ */
+async function fetchSemesterInfo(type) {
     try {
         const resp = await fetchWithTimeout(COURSE_ACTION_URL + "?setAction=userCourseSchedule&selectTableType=" + type);
         if (!resp.ok) return null;
-        return parseSemesterLabel(await resp.text());
+        const html = await resp.text();
+        return { label: parseSemesterLabel(html), creditMap: parseCreditMap(html) };
     } catch (e) {
-        console.error("探测学期名失败(", type, "):", e);
+        console.error("探测学期信息失败(", type, "):", e);
         return null;
     }
 }
@@ -447,29 +517,45 @@ async function fetchSemesterLabel(type) {
 /**
  * 让用户选择导入学期。用 showSingleSelection 显示两个学期的真实学期名
  * (如 "2025-2026第2学期" / "2026-2027第1学期",比"本学期/下学期"更明确);
- * 探测不到真实名时回退显示通用名。取消返回默认本学期(ThisTerm)。
+ * 探测不到真实名时回退显示通用名。取消或探测失败时回退默认本学期(ThisTerm)。
+ * 返回 { semesterType, creditMap }。
  */
 async function selectSemester() {
     const kinds = [
         { value: "ThisTerm", label: "本学期" },
         { value: "NextTerm", label: "下学期" }
     ];
-    const labels = await Promise.all(kinds.map(k => fetchSemesterLabel(k.value)));
-    const names = kinds.map((k, i) => labels[i] || k.label);
+    const infos = await Promise.all(kinds.map(k => fetchSemesterInfo(k.value)));
+    const names = kinds.map((k, i) => (infos[i] && infos[i].label) || k.label);
     const idx = await window.shiguangBridgePromise.showSingleSelection("选择导入学期", JSON.stringify(names), 0);
-    if (idx == null || idx === undefined || idx === -1) return SELECT_TABLE_TYPE;
-    return (kinds[idx] || kinds[0]).value;
+    const chosen = (idx == null || idx === undefined || idx === -1 || !kinds[idx]) ? 0 : idx;
+    return {
+        semesterType: kinds[chosen].value,
+        creditMap: (infos[chosen] && infos[chosen].creditMap) || {}
+    };
+}
+
+/**
+ * 按课程名把学分注入课程记录(选课页学分表 → 课表课程)。
+ * 匹配不到时保持原样(不设置该字段),不影响导入。
+ */
+function applyCredits(courses, creditMap) {
+    if (!creditMap || Object.keys(creditMap).length === 0) return courses;
+    for (const c of courses) {
+        const credit = creditMap[c.name];
+        if (typeof credit === "number") c.credit = credit;
+    }
+    return courses;
 }
 
 /**
  * 构造课表配置:学期总周数(取课程最大周)+ 开学日期(可选)。
- * 开学日期:请求第 1 周课表,取表头"星期一"日期作为第一周第一天("yyyy-MM-dd",官方格式);
- * 请求失败或解析失败时不设置该字段(App 端可手动设置),不影响课程导入。
+ * 开学日期:从已抓取的第 1 周课表表头取"星期一"日期(第一周第一天,"yyyy-MM-dd",官方格式);
+ * 解析失败时不设置该字段(App 端可手动设置),不影响课程导入。
  */
-async function fetchCourseConfig(semesterType, courses) {
+function fetchCourseConfig(week1Html, courses) {
     const maxWeek = courses.reduce((max, c) => Math.max(max, Math.max(...c.weeks)), 0);
     const config = { semesterTotalWeeks: Math.max(maxWeek, 1) };
-    const week1Html = await fetchWeek1Timetable(semesterType);
     if (week1Html) {
         const semesterStartDate = parseSemesterStartDate(week1Html);
         if (semesterStartDate) {
@@ -503,10 +589,12 @@ async function runImportFlow() {
         return;
     }
 
-    // 2. 选择导入学期(默认本学期),并拉取该学期课表、检测登录状态
-    const semesterType = await selectSemester();
+    // 2. 选择导入学期(默认本学期),逐周抓取该学期课表并合并
+    const { semesterType, creditMap } = await selectSemester();
     BRIDGE.showToast("正在获取课表数据...");
-    const result = await fetchTimetableOnce(semesterType);
+    const result = await fetchAllWeeksTimetable(semesterType, (done, total) => {
+        BRIDGE.showToast(`正在获取课表数据...(${done}/${total} 周)`);
+    });
     if (result.error === "not_logged_in") {
         await BRIDGE_P.showAlert(
             "未检测到登录状态",
@@ -516,22 +604,22 @@ async function runImportFlow() {
         BRIDGE.showToast("未登录,导入中止");
         return;
     }
-    if (result.error === "network" || result.error === "request_failed") {
+    if (result.error === "network") {
         BRIDGE.showToast("获取课表失败(网络异常或超时),请检查网络后重试");
         return;
     }
-    if (result.error === "no_table" || !result.html) {
+    if (result.error === "no_table") {
         BRIDGE.showToast("未获取到课表数据,请确认登录状态后重试");
         return;
     }
-    const html = result.html;
 
-    // 3. 解析课程(所选学期)
-    const courses = parseTimetableToCourses(html);
+    // 3. 课程已逐周合并去重,这里按课名注入学分(选课页学分表)
+    const courses = result.courses || [];
     if (courses.length === 0) {
         BRIDGE.showToast("未解析到课程数据,请确认所选学期是否有课程");
         return;
     }
+    applyCredits(courses, creditMap);
     console.log("解析到课程记录数:", courses.length, courses);
 
     // 4. 保存课程
@@ -543,18 +631,18 @@ async function runImportFlow() {
         return;
     }
 
-    // 5. 保存作息时间(优先从课表"上课时间"列动态提取,学校调作息后无需改代码;提取失败回退写死常量)
+    // 5. 保存作息时间(从课表"上课时间"列动态提取,学校调作息后无需改代码;提取失败回退写死常量)
     try {
-        const timeSlots = parseTimeSlots(html) || TIME_SLOTS;
+        const timeSlots = parseTimeSlots(result.week1Html) || TIME_SLOTS;
         await BRIDGE_P.savePresetTimeSlots(JSON.stringify(timeSlots));
         BRIDGE.showToast("作息时间保存成功");
     } catch (e) {
         BRIDGE.showToast("作息时间保存失败: " + e.message);
     }
 
-    // 6. 保存课表配置(学期总周数 + 开学日期)
+    // 6. 保存课表配置(学期总周数 + 开学日期,均取自第 1 周响应)
     try {
-        const config = await fetchCourseConfig(semesterType, courses);
+        const config = fetchCourseConfig(result.week1Html, courses);
         await BRIDGE_P.saveCourseConfig(JSON.stringify(config));
     } catch (e) {
         BRIDGE.showToast("课表配置保存失败: " + e.message);

+ 8 - 0
resources/HZU/adapters.yaml

@@ -0,0 +1,8 @@
+adapters:
+  - adapter_id: "HZU"
+    adapter_name: "菏泽学院强智教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "hzu.js"
+    import_url: "https://222.206.176.102/jsxsd/"
+    maintainer: "yukoimi"
+    description: "登录后即可导入学期理论课表,支持学期选择、单双周、单节课、教学周历及冬夏季作息自动切换(5月1日夏季、10月5日冬季)。"

+ 311 - 0
resources/HZU/hzu.js

@@ -0,0 +1,311 @@
+// 菏泽学院强智教务:新版学期理论课表(viweType=0)。
+// 参考仓库 CSUFT/MKU 的新版强智 DOM 结构,按本站实测重写。
+// 不保存登录凭据;所有请求沿用当前 WebView 的会话与协议。
+
+const HZU_SUMMER_TIME_SLOTS = [
+    { number: 1, startTime: "08:00", endTime: "08:50" },
+    { number: 2, startTime: "09:00", endTime: "09:50" },
+    { number: 3, startTime: "10:10", endTime: "11:00" },
+    { number: 4, startTime: "11:10", endTime: "12:00" },
+    { number: 5, startTime: "14:30", endTime: "15:20" },
+    { number: 6, startTime: "15:30", endTime: "16:20" },
+    { number: 7, startTime: "16:40", endTime: "17:30" },
+    { number: 8, startTime: "17:40", endTime: "18:30" },
+    { number: 9, startTime: "19:30", endTime: "20:20" },
+    { number: 10, startTime: "20:30", endTime: "21:20" }
+];
+
+// 冬季仅下午第5至8节提前半小时,上午和晚课保持不变。
+const HZU_WINTER_TIME_SLOTS = HZU_SUMMER_TIME_SLOTS.map(slot => {
+    if (slot.number < 5 || slot.number > 8) return { ...slot };
+    const earlier = time => {
+        const [hour, minute] = time.split(":").map(Number);
+        const minutes = hour * 60 + minute - 30;
+        return String(Math.floor(minutes / 60)).padStart(2, "0") + ":" +
+            String(minutes % 60).padStart(2, "0");
+    };
+    return { ...slot, startTime: earlier(slot.startTime), endTime: earlier(slot.endTime) };
+});
+
+function hzuComboSchedule(semesterId) {
+    const term = semesterId.match(/^(\d{4})-(\d{4})-([12])$/);
+    if (!term || Number(term[2]) !== Number(term[1]) + 1) {
+        throw new Error("无法识别作息所属学年。");
+    }
+    const firstYear = Number(term[1]);
+    const lastYear = Number(term[2]);
+    const summerYear = term[3] === "1" ? firstYear : lastYear;
+    const publicSchedules = [
+        {
+            name: "菏泽学院夏季作息",
+            startDate: summerYear + "-05-01",
+            endDate: summerYear + "-10-04",
+            defaultClassDuration: 50,
+            defaultBreakDuration: 10,
+            timeSlots: HZU_SUMMER_TIME_SLOTS.filter(slot => slot.number >= 5 && slot.number <= 8)
+        }
+    ];
+    // App 在夏季规则之外回退到冬季基础作息,边界日期均为闭区间。
+    return { name: "菏泽学院冬夏季作息", publicSchedules };
+}
+
+function hzuUrl(path) {
+    const pathname = window.location.pathname;
+    const index = pathname.indexOf("/jsxsd/");
+    if (index < 0 && !pathname.endsWith("/jsxsd")) {
+        throw new Error("请先登录菏泽学院教务系统,再开始导入。");
+    }
+    const prefix = index >= 0 ? pathname.slice(0, index) : pathname.slice(0, -6);
+    return window.location.origin + prefix + "/jsxsd" + path;
+}
+
+async function hzuFetchDocument(path) {
+    const controller = new AbortController();
+    const timer = setTimeout(() => controller.abort(), 20000);
+    try {
+        const response = await fetch(hzuUrl(path), {
+            credentials: "include",
+            signal: controller.signal
+        });
+        if (!response.ok) throw new Error("教务请求失败:HTTP " + response.status);
+        return new DOMParser().parseFromString(await response.text(), "text/html");
+    } finally {
+        clearTimeout(timer);
+    }
+}
+
+function hzuSemesters(doc) {
+    const select = doc.querySelector("#xnxq01id");
+    if (!select) throw new Error("登录已失效或未获取到课表页面,请重新登录。");
+    return Array.from(select.options).filter(option => option.value).map(option => ({
+        value: option.value,
+        text: option.textContent.trim(),
+        selected: option.selected
+    }));
+}
+
+function hzuCheckSemester(doc, semesterId) {
+    const value = doc.querySelector("#xnxq")?.value || doc.querySelector("#xnxq01id")?.value;
+    if (value !== semesterId) throw new Error("教务返回的学期与所选学期不一致,请重新查询。");
+}
+
+function hzuNumbers(text, limit) {
+    const numbers = [];
+    const parts = text.replace(/\s/g, "").split(/[,,、]/);
+    for (const part of parts) {
+        const match = part.match(/^(\d+)(?:[-~~—至](\d+))?$/);
+        if (!match) throw new Error("无法识别周次或节次:" + text);
+        const start = Number(match[1]);
+        const end = Number(match[2] || match[1]);
+        if (start < 1 || end < start || end > limit) throw new Error("周次或节次超出范围:" + text);
+        for (let number = start; number <= end; number++) numbers.push(number);
+    }
+    return Array.from(new Set(numbers)).sort((a, b) => a - b);
+}
+
+function hzuParseTime(text) {
+    const sectionMatch = text.match(/[\[[]([^[\][]]+)节[\]]]/);
+    if (!sectionMatch || !text.slice(0, sectionMatch.index).includes("周")) {
+        throw new Error("课程缺少有效的周次或节次:" + text);
+    }
+    const weekText = text.slice(0, sectionMatch.index).replace(/第|周|\s/g, "");
+    const weeks = [];
+    for (const part of weekText.split(/[,,、]/)) {
+        const parity = part.match(/[((]?(单|双)[))]?$/);
+        const range = parity ? part.slice(0, parity.index) : part;
+        weeks.push(...hzuNumbers(range, 100).filter(week =>
+            !parity || week % 2 === (parity[1] === "单" ? 1 : 0)));
+    }
+    const sections = hzuNumbers(sectionMatch[1], 30);
+    // 不连续的节次拆开,不能把未上课的小节填进连续区间。
+    const ranges = [];
+    for (const section of sections) {
+        const last = ranges[ranges.length - 1];
+        if (last && last.endSection + 1 === section) last.endSection = section;
+        else ranges.push({ startSection: section, endSection: section });
+    }
+    const sortedWeeks = Array.from(new Set(weeks)).sort((a, b) => a - b);
+    if (!sortedWeeks.length) throw new Error("课程周次为空:" + text);
+    return { weeks: sortedWeeks, ranges };
+}
+
+function hzuGrid(table) {
+    const grid = [];
+    Array.from(table.rows).forEach((row, r) => {
+        if (!grid[r]) grid[r] = [];
+        let column = 0;
+        Array.from(row.cells).forEach(cell => {
+            while (grid[r][column]) column++;
+            for (let dr = 0; dr < (cell.rowSpan || 1); dr++) {
+                if (!grid[r + dr]) grid[r + dr] = [];
+                for (let dc = 0; dc < (cell.colSpan || 1); dc++) {
+                    grid[r + dr][column + dc] = cell;
+                }
+            }
+            column += cell.colSpan || 1;
+        });
+    });
+    return grid;
+}
+
+function hzuField(text, label) {
+    return (text.match(new RegExp(label + "[::]\\s*([^;;]*)")) || [])[1]?.trim() || "";
+}
+
+function hzuParseCourses(doc) {
+    const table = doc.querySelector('td[name="kbDataTd"]')?.closest("table");
+    if (!table) {
+        if (doc.querySelector("#xnxq01id")) return [];
+        throw new Error("未获取到课表,请重新登录教务系统。");
+    }
+    const grid = hzuGrid(table);
+    const days = new Map();
+    const dayNames = "一二三四五六日";
+    for (const row of grid) {
+        row.forEach((cell, column) => {
+            if (cell.tagName !== "TH") return;
+            const match = cell.textContent.trim().match(/^(?:星期|周)([一二三四五六日天])/);
+            if (match) days.set(column, match[1] === "天" ? 7 : dayNames.indexOf(match[1]) + 1);
+        });
+    }
+    if (days.size !== 7) throw new Error("无法识别课表的星期表头,已停止导入。");
+
+    const seen = new Set();
+    const merged = new Map();
+    for (const row of grid) {
+        for (const [column, day] of days) {
+            const cell = row[column];
+            if (!cell || seen.has(cell) || cell.getAttribute("name") !== "kbDataTd") continue;
+            seen.add(cell);
+            // 只读简表课程,跳过同一格内 tooltip 的重复详情。
+            for (const item of cell.querySelectorAll("ul.courselists > li.courselists-item")) {
+                const name = item.querySelector(".qz-hasCourse-title")?.textContent.trim();
+                const info = item.querySelector(".qz-hasCourse-abbrinfo")?.textContent.trim() || "";
+                if (!name) throw new Error("课表中有无法识别的课程名称,已停止导入。");
+                const time = hzuParseTime(hzuField(info, "时间"));
+                const teacher = hzuField(info, "老师");
+                const location = hzuField(info, "地点");
+                const position = /^[((]\s*[))]$/.test(location) ? "" : location;
+                for (const range of time.ranges) {
+                    const key = JSON.stringify([name, teacher, position, day, range.startSection, range.endSection]);
+                    if (merged.has(key)) {
+                        const course = merged.get(key);
+                        course.weeks = Array.from(new Set(course.weeks.concat(time.weeks))).sort((a, b) => a - b);
+                    } else {
+                        merged.set(key, { name, teacher, position, day, ...range, weeks: time.weeks.slice() });
+                    }
+                }
+            }
+        }
+    }
+    return Array.from(merged.values());
+}
+
+function hzuCalendar(doc, semesterId) {
+    hzuCheckSemester(doc, semesterId);
+    const table = doc.querySelector("#dataTable");
+    if (!table) throw new Error("未找到教学周历。");
+    const anchors = [];
+    let totalWeeks = 0;
+    for (const row of table.rows) {
+        const match = row.cells[0]?.textContent.match(/第\s*(\d+)\s*周/);
+        if (!match) continue;
+        const week = Number(match[1]);
+        if (week < 1 || week > 100) throw new Error("教学周历周次异常。");
+        totalWeeks = Math.max(totalWeeks, week);
+        for (let day = 0; day < 7; day++) {
+            const cell = row.cells[day + 1];
+            const date = cell?.textContent.match(/(\d{1,2})月(\d{1,2})日/);
+            if (date) anchors.push({ offset: (week - 1) * 7 + day, month: Number(date[1]), day: Number(date[2]) });
+        }
+    }
+    if (!anchors.length || !totalWeeks) throw new Error("教学周历缺少日期。");
+    anchors.sort((a, b) => a.offset - b.offset);
+    const term = semesterId.match(/^(\d{4})-\d{4}-([12])$/);
+    if (!term) throw new Error("无法识别校历学期。");
+    const first = anchors[0];
+    const year = Number(term[1]) + (term[2] === "2" || first.month < 7 ? 1 : 0);
+    const dayMs = 86400000;
+    const startMs = Date.UTC(year, first.month - 1, first.day) - first.offset * dayMs;
+    if (new Date(startMs).getUTCDay() !== 1 || anchors.some(anchor => {
+        const date = new Date(startMs + anchor.offset * dayMs);
+        return date.getUTCMonth() + 1 !== anchor.month || date.getUTCDate() !== anchor.day;
+    })) throw new Error("教学周历日期不一致,无法确定开学日期。");
+    return { semesterStartDate: new Date(startMs).toISOString().slice(0, 10), semesterTotalWeeks: totalWeeks };
+}
+
+async function hzuRunImportFlow() {
+    const bridge = window.shiguangBridge;
+    const api = window.shiguangBridgePromise;
+    if (!bridge || !api) throw new Error("未检测到拾光桥接接口,请在软件或测试插件中运行。");
+    const requiredMethods = ["showAlert", "showSingleSelection", "saveCourseConfig",
+        "savePresetTimeSlots", "saveComboSchedule", "saveImportedCourses"];
+    if (requiredMethods.some(method => typeof api[method] !== "function") ||
+        typeof bridge.showToast !== "function" || typeof bridge.notifyTaskCompletion !== "function") {
+        throw new Error("桥接接口不完整,请使用时光课程表 2.1.0 或新版测试插件后重试。");
+    }
+    if (!await api.showAlert("菏泽学院课表导入", "请确认已登录教务系统。将读取所选学期的全部周课程。", "开始导入")) return;
+
+    bridge.showToast("正在读取学期列表...");
+    const firstDoc = await hzuFetchDocument("/xskb/xskb_list.do?viweType=0&zc=");
+    const semesters = hzuSemesters(firstDoc);
+    if (!semesters.length) throw new Error("教务未提供可导入的学期。");
+    const index = await api.showSingleSelection("选择学期",
+        JSON.stringify(semesters.map(semester => semester.text)),
+        Math.max(0, semesters.findIndex(semester => semester.selected)));
+    if (index === null) return;
+    if (!Number.isInteger(index) || !semesters[index]) throw new Error("学期选择无效。");
+    const semesterId = semesters[index].value;
+    const params = new URLSearchParams({ viweType: "0", zc: "", xnxq01id: semesterId });
+    const modeSelect = firstDoc.querySelector("#kbjcmsid");
+    const mode = modeSelect && "value" in modeSelect ? modeSelect.value : null;
+    if (typeof mode === "string" && mode) params.set("kbjcmsid", mode);
+    const doc = await hzuFetchDocument("/xskb/xskb_list.do?" + params);
+    hzuCheckSemester(doc, semesterId);
+    const courses = hzuParseCourses(doc);
+    if (!courses.length) {
+        bridge.showToast("该学期暂无已排课课程,未修改原有课表。");
+        return;
+    }
+
+    let config = null;
+    try {
+        config = hzuCalendar(await hzuFetchDocument("/jxzl/jxzl_query?xnxq01id=" +
+            encodeURIComponent(semesterId)), semesterId);
+        if (courses.some(course => course.weeks.some(week => week > config.semesterTotalWeeks))) {
+            throw new Error("课程周次超出教学周历。");
+        }
+    } catch (_) {
+        config = null;
+        if (!await api.showAlert("教学周历不可用",
+            "无法确定所选学期的开学日期与总周数。继续导入后,请在课表设置中核对学期配置。", "继续导入")) return;
+    }
+
+    const comboSchedule = hzuComboSchedule(semesterId);
+
+    // 只在全部读取和确认完成后写入;保存失败不能发出完成信号。
+    if (config) {
+        config.firstDayOfWeek = 1;
+        config.defaultClassDuration = 50;
+        config.defaultBreakDuration = 10;
+        if (await api.saveCourseConfig(JSON.stringify(config)) !== true) throw new Error("学期配置保存失败。");
+    }
+    if (await api.savePresetTimeSlots(JSON.stringify(HZU_WINTER_TIME_SLOTS)) !== true) {
+        throw new Error("作息时间保存失败。");
+    }
+    // App 要求先保存基础节次,成功后再绑定组合作息,之后不能重存基础节次。
+    if (await api.saveComboSchedule(JSON.stringify(comboSchedule)) !== true) {
+        throw new Error("冬夏季作息保存失败。");
+    }
+    if (await api.saveImportedCourses(JSON.stringify(courses)) !== true) throw new Error("课程保存失败。");
+    bridge.showToast("成功导入 " + courses.length + " 条上课记录");
+    bridge.notifyTaskCompletion();
+}
+
+// Start import.
+hzuRunImportFlow().catch(error => {
+    console.error("HZU import failed:", error);
+    if (typeof window.shiguangBridge?.showToast === "function") {
+        window.shiguangBridge.showToast("导入失败:" + (error?.message || String(error)));
+    }
+});

+ 222 - 59
resources/QDU/qdu_01.js

@@ -2,10 +2,12 @@
 // 由本校开发者JellyfisHawthorn适配
 // 出现问题请提联系开发者或者提交pr更改,这更加快速
 
-
 const BASE_URL = "https://xjw.qdu.edu.cn";
 const URL_SEMESTER = `${BASE_URL}/jsxsd/jxzl/jxzl_query`;   // 教学周历(学期列表 + 开学日期 + 最大周次)
-const URL_COURSE   = `${BASE_URL}/jsxsd/xskb/xskb_list.do`; // 课表查询
+const URL_COURSE = `${BASE_URL}/jsxsd/xskb/xskb_list.do`; // 课表查询
+const URL_PLAN = `${BASE_URL}/jsxsd/pyfa/pyfa_query`; // 执行计划查询(用于查课程学分)
+const URL_FXPLAN = `${BASE_URL}/jsxsd/pyfa/fxpyfa_query`; // 辅修执行计划查询
+const URL_WZYPLAN = `${BASE_URL}/jsxsd/pyfa/wzypyfa_query`; // 微专业执行计划查询
 
 // 工具函数
 
@@ -47,11 +49,11 @@ function mergeAndDistinctCourses(courses) {
     // 阶段 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);
+            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 = [];
@@ -84,11 +86,11 @@ function mergeAndDistinctCourses(courses) {
     // 阶段 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);
+            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 = [];
@@ -119,7 +121,9 @@ function mergeAndDistinctCourses(courses) {
 
 // 核心解析逻辑
 
-function parseTimetableToModel(doc) {
+function parseTimetableToModel(doc, courseCredits) {
+    // 获取课程学分对象
+
     const timetable = doc.getElementById('timetable');
     if (!timetable) return [];
 
@@ -172,7 +176,8 @@ function parseTimetableToModel(doc) {
                             "position": position,
                             "day": day,
                             "startSection": startSection,
-                            "endSection": endSection
+                            "endSection": endSection,
+                            "credit": courseCredits[name] || null
                         });
                     }
                 });
@@ -224,6 +229,39 @@ async function requestCoursePage(semesterId) {
     return await response.text();
 }
 
+/**
+ * GET 执行计划页面(用于获取课程对应的学分)
+ */
+async function requestPlanPage() {
+    const response = await fetch(URL_PLAN, {
+        method: "GET",
+        credentials: "include"
+    });
+    return await response.text();
+}
+
+/**
+* GET 辅修执行计划页面(用于获取课程对应的学分)
+*/
+async function requestFxFPlanPage() {
+    const response = await fetch(URL_FXPLAN, {
+        method: "GET",
+        credentials: "include"
+    });
+    return await response.text();
+}
+
+/**
+* GET 微专业执行计划页面(用于获取课程对应的学分)
+*/
+async function requestWZYPlanPage() {
+    const response = await fetch(URL_WZYPLAN, {
+        method: "GET",
+        credentials: "include"
+    });
+    return await response.text();
+}
+
 // 业务逻辑
 
 /**
@@ -293,11 +331,71 @@ async function fetchSemesterInfo(semesterId) {
  * @returns {Promise<Array<Object>>}
  */
 async function fetchCourses(semesterId) {
+    const courseCredits = await fetchAllCourseCredits();
     const html = await requestCoursePage(semesterId);
-    // 打印html todo
-    return parseTimetableToModel(new DOMParser().parseFromString(html, "text/html"));
+    return parseTimetableToModel(new DOMParser().parseFromString(html, "text/html"), courseCredits);
 }
 
+/**
+ * 获取执行计划里所有课程对应的学分
+ * 部分限选、任选课的学分信息暂时无法获取
+ * @returns {Promise<Object>} 课程名称 -> 学分
+ */
+async function fetchAllCourseCredits() {
+
+    const courseCredits = {}; // 存储所有课程的学分信息
+
+    // 依次获取三个执行计划表的学分信息
+    const requestFns = [
+        requestPlanPage,
+        requestFxFPlanPage,
+        requestWZYPlanPage
+    ];
+
+    for (const requestFn of requestFns) {
+        const html = await requestFn();
+        const doc = new DOMParser().parseFromString(html, "text/html");
+        const dataTable = doc.getElementById("dataList");
+
+        const rows = Array.from(dataTable.querySelectorAll('tr')).filter(r => r.querySelector('td'));
+
+        // 如果rows的第一项内容只有“未查询到数据”文本,说明没有数据,跳过后续处理
+        if (rows[0].querySelector('td').textContent.trim() === "未查询到数据") {
+            continue;
+        }
+
+        rows.forEach((row) => {
+
+            // 每一行的第四个单元格是课程名称,第六个单元格是学分
+            const courseName = row.querySelector('td:nth-child(4)').textContent.trim();
+            const credit = row.querySelector('td:nth-child(6)').textContent.trim();
+            if (courseName && !(courseName in courseCredits)) {
+                courseCredits[courseName] = parseFloat(credit);
+            }
+        });
+    }
+
+    return courseCredits;
+
+}
+
+/**
+ * 获取某课程的学分
+ * @param {string} courseName 课程名称 
+ * @param {Object} courseCredits 学分信息对象
+ * @returns {Promise<number|null>}
+ */
+async function fetchCourseCredit(courseName, courseCredits) {
+    if (courseName in courseCredits) {
+        return courseCredits[courseName];
+    } else {
+        window.shiguangBridge.showToast(`${courseName}的学分信息未找到!`);
+        return null;
+    }
+}
+
+
+
 // 配置保存
 
 async function saveAppConfig(semesterStartDate, totalWeeks) {
@@ -315,52 +413,117 @@ async function saveAppConfig(semesterStartDate, totalWeeks) {
 /**
  * 统一作息
  */
-async function saveAppTimeSlots() {
-    // 分别定义春夏季和秋冬季的作息时间表。注:T2时段仅供参考(因为我没上过且没找到官方具体时间)
-    const ssTimeSlots = [
-        { "number": 1,  "startTime": "08:00", "endTime": "08:50"},
-        { "number": 2,  "startTime": "09:00", "endTime": "09:50"},
-        { "number": 3,  "startTime": "10:10", "endTime": "11:00"},
-        { "number": 4,  "startTime": "11:10", "endTime": "12:00"},
-        { "number": 5,  "startTime": "13:10", "endTime": "14:00"},
-        { "number": 6,  "startTime": "14:00", "endTime": "14:50"},
-        { "number": 7,  "startTime": "15:00", "endTime": "15:50"},
-        { "number": 8,  "startTime": "16:00", "endTime": "16:50"},
-        { "number": 9,  "startTime": "17:00", "endTime": "17:50"},
-        { "number": 10, "startTime": "17:50", "endTime": "18:30"},
-        { "number": 11, "startTime": "19:00", "endTime": "19:50"},
-        { "number": 12, "startTime": "20:00", "endTime": "20:50"},
-        { "number": 13, "startTime": "21:00", "endTime": "21:50"}
-    ]
-    const awTimeSlots = [
-        { "number": 1,  "startTime": "08:00", "endTime": "08:50"},
-        { "number": 2,  "startTime": "09:00", "endTime": "09:50"},
-        { "number": 3,  "startTime": "10:10", "endTime": "11:00"},
-        { "number": 4,  "startTime": "11:10", "endTime": "12:00"},
-        { "number": 5,  "startTime": "12:40", "endTime": "13:30"},
-        { "number": 6,  "startTime": "13:30", "endTime": "14:20"},
-        { "number": 7,  "startTime": "14:30", "endTime": "15:20"},
-        { "number": 8,  "startTime": "15:30", "endTime": "16:20"},
-        { "number": 9,  "startTime": "16:30", "endTime": "17:20"},
-        { "number": 10, "startTime": "17:20", "endTime": "18:00"},
-        { "number": 11, "startTime": "18:30", "endTime": "19:20"},
-        { "number": 12, "startTime": "19:30", "endTime": "20:20"},
-        { "number": 13, "startTime": "20:30", "endTime": "21:20"}
+async function saveAppTimeSlots(semesterId) {
+    // 基础时间段
+    const baseTimeSlots = [
+        { "number": 1, "startTime": "08:00", "endTime": "08:50" },
+        { "number": 2, "startTime": "09:00", "endTime": "09:50" },
+        { "number": 3, "startTime": "10:10", "endTime": "11:00" },
+        { "number": 4, "startTime": "11:10", "endTime": "12:00" },
+        { "number": 5, "startTime": "13:10", "endTime": "14:00" },
+        { "number": 6, "startTime": "14:00", "endTime": "14:50" },
+        { "number": 7, "startTime": "15:00", "endTime": "15:50" },
+        { "number": 8, "startTime": "16:00", "endTime": "16:50" },
+        { "number": 9, "startTime": "17:00", "endTime": "17:50" },
+        { "number": 10, "startTime": "17:50", "endTime": "18:30" },
+        { "number": 11, "startTime": "19:00", "endTime": "19:50" },
+        { "number": 12, "startTime": "20:00", "endTime": "20:50" },
+        { "number": 13, "startTime": "21:00", "endTime": "21:50" }
     ];
 
-    // 用户选择作息时间表,春夏还是秋冬
-    const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
-        "选择作息时间", JSON.stringify(["春夏季作息", "秋冬季作息"]), 0
-    );
-    let timeSlots = [];
-    if (selectedIndex === null) return;
-    else if (selectedIndex === 0) {
-        timeSlots = ssTimeSlots;
-    } else {
-        timeSlots = awTimeSlots;
+    // 创建当前学期的组合作息方案。注:T2时段(第10节)仅供参考(因为我没上过且没找到官方具体时间)
+    // 根据semesterId获取此学期的信息
+    const [startYear, endYear, semester] = semesterId.split("-");
+    let comboSchedule = null;
+    if (semester === "1") {
+        // 秋冬学期
+        comboSchedule = {
+            name: `${startYear}秋组合作息方案`,
+            publicSchedules: [
+                {
+                    name: `${startYear}春夏作息`,
+                    startDate: `${startYear}-05-01`,
+                    endDate: `${startYear}-09-30`,
+                    timeSlots: [
+                        { "number": 5, "startTime": "13:10", "endTime": "14:00" },
+                        { "number": 6, "startTime": "14:00", "endTime": "14:50" },
+                        { "number": 7, "startTime": "15:00", "endTime": "15:50" },
+                        { "number": 8, "startTime": "16:00", "endTime": "16:50" },
+                        { "number": 9, "startTime": "17:00", "endTime": "17:50" },
+                        { "number": 10, "startTime": "17:50", "endTime": "18:30" },
+                        { "number": 11, "startTime": "19:00", "endTime": "19:50" },
+                        { "number": 12, "startTime": "20:00", "endTime": "20:50" },
+                        { "number": 13, "startTime": "21:00", "endTime": "21:50" }
+                    ]
+                },
+                {
+                    name: `${startYear}秋冬作息`,
+                    startDate: `${startYear}-10-01`,
+                    endDate: `${endYear}-04-30`,
+                    timeSlots: [
+                        { "number": 5, "startTime": "12:40", "endTime": "13:30" },
+                        { "number": 6, "startTime": "13:30", "endTime": "14:20" },
+                        { "number": 7, "startTime": "14:30", "endTime": "15:20" },
+                        { "number": 8, "startTime": "15:30", "endTime": "16:20" },
+                        { "number": 9, "startTime": "16:30", "endTime": "17:20" },
+                        { "number": 10, "startTime": "17:20", "endTime": "18:00" },
+                        { "number": 11, "startTime": "18:30", "endTime": "19:20" },
+                        { "number": 12, "startTime": "19:30", "endTime": "20:20" },
+                        { "number": 13, "startTime": "20:30", "endTime": "21:20" }
+                    ]
+                }
+            ]
+        };
+    } else if (semester === "2") {
+        // 春夏学期
+        comboSchedule = {
+            name: `${endYear}春组合作息方案`,
+            publicSchedules: [
+                {
+                    name: `${startYear}秋冬作息`,
+                    startDate: `${startYear}-10-01`,
+                    endDate: `${endYear}-04-30`,
+                    timeSlots: [
+                        { "number": 5, "startTime": "12:40", "endTime": "13:30" },
+                        { "number": 6, "startTime": "13:30", "endTime": "14:20" },
+                        { "number": 7, "startTime": "14:30", "endTime": "15:20" },
+                        { "number": 8, "startTime": "15:30", "endTime": "16:20" },
+                        { "number": 9, "startTime": "16:30", "endTime": "17:20" },
+                        { "number": 10, "startTime": "17:20", "endTime": "18:00" },
+                        { "number": 11, "startTime": "18:30", "endTime": "19:20" },
+                        { "number": 12, "startTime": "19:30", "endTime": "20:20" },
+                        { "number": 13, "startTime": "20:30", "endTime": "21:20" }
+                    ]
+                },
+                {
+                    name: `${endYear}春夏作息`,
+                    startDate: `${endYear}-05-01`,
+                    endDate: `${endYear}-09-30`,
+                    timeSlots: [
+                        { "number": 5, "startTime": "13:10", "endTime": "14:00" },
+                        { "number": 6, "startTime": "14:00", "endTime": "14:50" },
+                        { "number": 7, "startTime": "15:00", "endTime": "15:50" },
+                        { "number": 8, "startTime": "16:00", "endTime": "16:50" },
+                        { "number": 9, "startTime": "17:00", "endTime": "17:50" },
+                        { "number": 10, "startTime": "17:50", "endTime": "18:30" },
+                        { "number": 11, "startTime": "19:00", "endTime": "19:50" },
+                        { "number": 12, "startTime": "20:00", "endTime": "20:50" },
+                        { "number": 13, "startTime": "21:00", "endTime": "21:50" }
+                    ]
+                }
+            ]
+        };
     }
 
-    return await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
+    // 保存作息方案
+    try {
+        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(baseTimeSlots));
+        await window.shiguangBridgePromise.saveComboSchedule(JSON.stringify(comboSchedule));
+        return true;
+    } catch (error) {
+        window.shiguangBridge.showToast("作息方案导入失败: " + error.message);
+        return false;
+    }
 }
 
 // 流程编排
@@ -403,7 +566,7 @@ async function runImportFlow() {
 
         // 6. 保存配置(含开学日期 + 最大周次(最大限制为 20))/ 作息 / 课程
         await saveAppConfig(semesterStartDate, Math.min(totalWeeks, 20));
-        await saveAppTimeSlots();
+        await saveAppTimeSlots(semesterId);
         await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(finalCourses));
 
         // 7. 完成