yu.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  1. /**
  2. * 长江大学(树维教务系统)课表导入适配脚本
  3. *
  4. * 树维教务系统特点:
  5. * 1. 课表以空 HTML 表格返回,课程数据通过 JavaScript 脚本动态注入
  6. * 2. 脚本中包含 `new TaskActivity(...)` 构造函数调用来定义课程
  7. * 3. 需要从脚本文本中直接提取课程信息,而不是解析 DOM
  8. *
  9. * 适用于使用树维教务系统的其他高校(需修改 BASE 地址)
  10. */
  11. (function () {
  12. const BASE = "https://jwc3-yangtzeu-edu-cn-s.atrust.yangtzeu.edu.cn";
  13. const MAX_PREVIEW_LEN = 300;
  14. const diagState = {
  15. currentStep: "init",
  16. events: []
  17. };
  18. function truncateText(value, maxLen) {
  19. const text = String(value == null ? "" : value);
  20. if (text.length <= maxLen) return text;
  21. return `${text.slice(0, maxLen)}...<truncated ${text.length - maxLen} chars>`;
  22. }
  23. function toSafeJson(value) {
  24. try {
  25. return JSON.stringify(value);
  26. } catch (_) {
  27. return String(value);
  28. }
  29. }
  30. function recordDiag(step, info) {
  31. diagState.currentStep = step || diagState.currentStep;
  32. diagState.events.push({
  33. at: new Date().toISOString(),
  34. step: diagState.currentStep,
  35. info: info || ""
  36. });
  37. if (diagState.events.length > 80) {
  38. diagState.events = diagState.events.slice(-80);
  39. }
  40. }
  41. function createImportError(step, message, context, cause) {
  42. const error = new Error(message || "导入失败");
  43. error.name = "ImportFlowError";
  44. error.step = step || diagState.currentStep;
  45. error.context = context || {};
  46. error.cause = cause;
  47. return error;
  48. }
  49. function formatErrorDetails(error) {
  50. const lines = [];
  51. const err = error || {};
  52. const step = err.step || diagState.currentStep || "unknown";
  53. const now = new Date().toISOString();
  54. lines.push(`Time: ${now}`);
  55. lines.push(`Step: ${step}`);
  56. lines.push(`Name: ${err.name || "Error"}`);
  57. lines.push(`Message: ${err.message || String(err)}`);
  58. if (err.stack) {
  59. lines.push("Stack:");
  60. lines.push(String(err.stack));
  61. }
  62. if (err.context && Object.keys(err.context).length > 0) {
  63. lines.push("Context:");
  64. lines.push(truncateText(toSafeJson(err.context), 1500));
  65. }
  66. if (err.cause) {
  67. const causeMsg = err.cause && err.cause.message ? err.cause.message : String(err.cause);
  68. lines.push(`Cause: ${causeMsg}`);
  69. if (err.cause && err.cause.stack) {
  70. lines.push("CauseStack:");
  71. lines.push(String(err.cause.stack));
  72. }
  73. }
  74. if (diagState.events.length > 0) {
  75. lines.push("Trace:");
  76. const recentEvents = diagState.events.slice(-20);
  77. recentEvents.forEach((event) => {
  78. lines.push(`[${event.at}] ${event.step} | ${truncateText(event.info, 200)}`);
  79. });
  80. }
  81. return lines.join("\n");
  82. }
  83. function extractCourseHtmlDebugInfo(courseHtml) {
  84. const text = String(courseHtml || "");
  85. const hasTaskActivity = /new\s+TaskActivity\s*\(/i.test(text);
  86. const hasUnitCount = /\bvar\s+unitCount\s*=\s*\d+/i.test(text);
  87. return {
  88. responseLength: text.length,
  89. hasTaskActivity,
  90. hasUnitCount,
  91. headPreview: truncateText(text.slice(0, 2000), 2000),
  92. tailPreview: truncateText(text.slice(-1000), 1000)
  93. };
  94. }
  95. function safeToast(message) {
  96. try {
  97. window.AndroidBridge && AndroidBridge.showToast(String(message || ""));
  98. } catch (_) {
  99. console.log("[Toast Fallback]", message);
  100. }
  101. }
  102. async function safeShowDetailedError(title, details) {
  103. const text = truncateText(details, 3500);
  104. try {
  105. if (window.AndroidBridgePromise && typeof window.AndroidBridgePromise.showAlert === "function") {
  106. await window.AndroidBridgePromise.showAlert(title || "导入失败", text, "确定");
  107. return;
  108. }
  109. } catch (alertError) {
  110. console.warn("[Error Alert Fallback] showAlert failed:", alertError);
  111. }
  112. safeToast(title || "导入失败");
  113. console.error("[Detailed Error]", text);
  114. }
  115. function ensureBridgePromise() {
  116. if (!window.AndroidBridgePromise) {
  117. throw new Error("AndroidBridgePromise 不可用,无法进行导入交互。");
  118. }
  119. }
  120. async function requestText(url, options) {
  121. const requestOptions = {
  122. credentials: "include",
  123. ...options
  124. };
  125. const method = requestOptions.method || "GET";
  126. recordDiag("http_request", `${method} ${url}`);
  127. let res;
  128. try {
  129. res = await fetch(url, requestOptions);
  130. } catch (networkError) {
  131. throw createImportError(
  132. "http_request",
  133. `网络请求失败: ${method} ${url}`,
  134. {
  135. url,
  136. method,
  137. bodyPreview: truncateText(requestOptions.body, MAX_PREVIEW_LEN)
  138. },
  139. networkError
  140. );
  141. }
  142. const text = await res.text();
  143. recordDiag("http_response", `${method} ${url} -> ${res.status}, len=${text.length}`);
  144. if (!res.ok) {
  145. throw createImportError("http_response", `请求失败(${res.status}): ${url}`, {
  146. url,
  147. method,
  148. status: res.status,
  149. bodyPreview: truncateText(requestOptions.body, MAX_PREVIEW_LEN),
  150. responsePreview: truncateText(text, MAX_PREVIEW_LEN)
  151. });
  152. }
  153. return text;
  154. }
  155. // 从入口页面 HTML 中提取学生 ID 和学期选择组件的 tagId
  156. // 树维系统通过 bg.form.addInput 注入学生 ID,通过 semesterBar 提供学期选择
  157. function parseEntryParams(entryHtml) {
  158. const idsMatch = entryHtml.match(/bg\.form\.addInput\(form,"ids","(\d+)"\)/);
  159. const tagIdMatch = entryHtml.match(/id="(semesterBar\d+Semester)"/);
  160. return {
  161. studentId: idsMatch ? idsMatch[1] : "",
  162. tagId: tagIdMatch ? tagIdMatch[1] : ""
  163. };
  164. }
  165. // 解析学期列表,树维接口返回的是 JavaScript 对象字面量(非标准 JSON)
  166. // 格式: { semesters: { "2024-2025-1": [{id: 389, schoolYear: "2024-2025", name: "1"}] } }
  167. function parseSemesterResponse(rawText) {
  168. let data;
  169. try {
  170. // 使用 Function 构造器执行对象字面量文本
  171. data = Function(`return (${String(rawText || "").trim()});`)();
  172. } catch (parseError) {
  173. throw createImportError(
  174. "parse_semester",
  175. "学期数据解析失败",
  176. { rawPreview: truncateText(rawText, MAX_PREVIEW_LEN) },
  177. parseError
  178. );
  179. }
  180. const semesters = [];
  181. if (!data || !data.semesters || typeof data.semesters !== "object") {
  182. return semesters;
  183. }
  184. Object.keys(data.semesters).forEach((k) => {
  185. const arr = data.semesters[k];
  186. if (!Array.isArray(arr)) return;
  187. arr.forEach((s) => {
  188. if (!s || !s.id) return;
  189. semesters.push({
  190. id: String(s.id),
  191. name: `${s.schoolYear || ""} 第${s.name || ""}学期`.trim()
  192. });
  193. });
  194. });
  195. return semesters;
  196. }
  197. // 清除课程名后面的课程序号
  198. function cleanCourseName(name) {
  199. return String(name || "").replace(/\(\d+\)\s*$/, "").trim();
  200. }
  201. // 解析周次位图字符串,树维系统使用位图表示课程在哪些周有效
  202. function parseValidWeeksBitmap(bitmap) {
  203. if (!bitmap || typeof bitmap !== "string") return [];
  204. const weeks = [];
  205. for (let i = 0; i < bitmap.length; i++) {
  206. if (bitmap[i] === "1" && i >= 1) weeks.push(i);
  207. }
  208. return weeks;
  209. }
  210. function normalizeWeeks(weeks) {
  211. const list = Array.from(new Set((weeks || []).filter((w) => Number.isInteger(w) && w > 0)));
  212. list.sort((a, b) => a - b);
  213. return list;
  214. }
  215. // 将教务系统的节次映射到 TimeSlots 编号
  216. // 教务系统返回的节次顺序: 1-6为正常排列,7为午间课,8为晚间课
  217. // TimeSlots 的顺序: 完全按时间排列,3为午间课,6为晚间课
  218. function mapSectionToTimeSlotNumber(section) {
  219. const mapping = {
  220. 1: 1,
  221. 2: 2,
  222. 3: 4,
  223. 4: 5,
  224. 5: 7,
  225. 6: 8,
  226. 7: 3,
  227. 8: 6
  228. };
  229. return mapping[section] || section;
  230. }
  231. // 反引号化 JavaScript 字面量字符串,处理转义字符
  232. function unquoteJsLiteral(token) {
  233. const text = String(token || "").trim();
  234. if (!text) return "";
  235. if (text === "null" || text === "undefined") return "";
  236. if ((text.startsWith("\"") && text.endsWith("\"")) || (text.startsWith("'") && text.endsWith("'"))) {
  237. const quote = text[0];
  238. let inner = text.slice(1, -1);
  239. inner = inner
  240. .replace(/\\\\/g, "\\")
  241. .replace(new RegExp(`\\\\${quote}`, "g"), quote)
  242. .replace(/\\n/g, "\n")
  243. .replace(/\\r/g, "\r")
  244. .replace(/\\t/g, "\t");
  245. return inner;
  246. }
  247. return text;
  248. }
  249. // 分割 JavaScript 函数参数字符串,正确处理引号和转义
  250. function splitJsArgs(argsText) {
  251. const args = [];
  252. let curr = "";
  253. let inQuote = "";
  254. let escaped = false;
  255. for (let i = 0; i < argsText.length; i++) {
  256. const ch = argsText[i];
  257. if (escaped) {
  258. curr += ch;
  259. escaped = false;
  260. continue;
  261. }
  262. if (ch === "\\") {
  263. curr += ch;
  264. escaped = true;
  265. continue;
  266. }
  267. if (inQuote) {
  268. curr += ch;
  269. if (ch === inQuote) inQuote = "";
  270. continue;
  271. }
  272. if (ch === "\"" || ch === "'") {
  273. curr += ch;
  274. inQuote = ch;
  275. continue;
  276. }
  277. if (ch === ",") {
  278. args.push(curr.trim());
  279. curr = "";
  280. continue;
  281. }
  282. curr += ch;
  283. }
  284. if (curr.trim() || argsText.endsWith(",")) {
  285. args.push(curr.trim());
  286. }
  287. return args;
  288. }
  289. /**
  290. * 从课表响应的 JavaScript 脚本中解析课程(树维教务核心解析逻辑)
  291. *
  292. * 树维系统返回的 HTML 中,表格单元格是空的,真正的课程数据在 <script> 中:
  293. * var unitCount = 8; // 每天的节次数
  294. * activity = new TaskActivity(teacherId, teacherName, courseId, courseName, ...);
  295. * index = day * unitCount + section; // 计算课程在二维表格中的位置
  296. * table0.activities[index] = activity;
  297. *
  298. * @param {string} htmlText - 课表响应的完整 HTML
  299. * @returns {Array} 课程数组
  300. */
  301. function parseCoursesFromTaskActivityScript(htmlText) {
  302. const text = String(htmlText || "");
  303. if (!text) return [];
  304. // 提取 unitCount(每天的节次数,通常为 8)
  305. const unitCountMatch = text.match(/\bvar\s+unitCount\s*=\s*(\d+)\s*;/);
  306. const unitCount = unitCountMatch ? parseInt(unitCountMatch[1], 10) : 0;
  307. if (!Number.isInteger(unitCount) || unitCount <= 0) return [];
  308. const courses = [];
  309. const stats = {
  310. blocks: 0,
  311. teacherRecovered: 0,
  312. teacherUnresolvedExpression: 0
  313. };
  314. // 匹配所有 TaskActivity 构造调用块
  315. // TaskActivity 参数顺序: teacherId, teacherName, courseId, courseName, classId, room, weekBitmap, ...
  316. const blockRe = /activity\s*=\s*new\s+TaskActivity\(([^]*?)\)\s*;\s*index\s*=\s*(?:(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)|(\d+))\s*;\s*table\d+\.activities\[index\]/g;
  317. let match;
  318. while ((match = blockRe.exec(text)) !== null) {
  319. stats.blocks += 1;
  320. const argsText = match[1] || "";
  321. const args = splitJsArgs(argsText);
  322. if (args.length < 7) continue;
  323. // 解析 index 计算表达式,确定星期几和第几节
  324. const dayPart = match[2];
  325. const sectionPart = match[3];
  326. const directIndexPart = match[4];
  327. let indexValue = -1;
  328. if (dayPart != null && sectionPart != null) {
  329. indexValue = parseInt(dayPart, 10) * unitCount + parseInt(sectionPart, 10);
  330. } else if (directIndexPart != null) {
  331. indexValue = parseInt(directIndexPart, 10);
  332. }
  333. if (!Number.isInteger(indexValue) || indexValue < 0) continue;
  334. // 从线性索引反推星期和节次
  335. const day = Math.floor(indexValue / unitCount) + 1;
  336. let section = (indexValue % unitCount) + 1;
  337. // 将教务系统的节次映射到 TimeSlots 编号
  338. section = mapSectionToTimeSlotNumber(section);
  339. if (day < 1 || day > 7 || section < 1 || section > 16) continue;
  340. // 提取课程字段:教师(args[1])、课程名(args[3])、教室(args[5])、周次位图(args[6])
  341. let teacher = unquoteJsLiteral(args[1]);
  342. // 如果教师名是表达式(如 actTeacherName.join(',')),反向解析真实姓名
  343. if (teacher && !/^['"]/.test(String(args[1]).trim()) && /join\s*\(/.test(String(args[1]))) {
  344. const resolved = resolveTeachersForTaskActivityBlock(text, match.index);
  345. if (resolved) {
  346. teacher = resolved;
  347. stats.teacherRecovered += 1;
  348. } else {
  349. stats.teacherUnresolvedExpression += 1;
  350. }
  351. }
  352. const name = cleanCourseName(unquoteJsLiteral(args[3]));
  353. const position = unquoteJsLiteral(args[5]);
  354. const weekBitmap = unquoteJsLiteral(args[6]);
  355. const weeks = normalizeWeeks(parseValidWeeksBitmap(weekBitmap));
  356. if (!name) continue;
  357. courses.push({
  358. name,
  359. teacher,
  360. position,
  361. day,
  362. startSection: section,
  363. endSection: section,
  364. weeks
  365. });
  366. }
  367. console.info("[课程解析 TaskActivity]", {
  368. blocks: stats.blocks,
  369. parsedCourses: courses.length,
  370. teacherRecovered: stats.teacherRecovered,
  371. teacherUnresolvedExpression: stats.teacherUnresolvedExpression
  372. });
  373. return mergeContiguousSections(courses);
  374. }
  375. // 从 TaskActivity 块前的代码中反解析教师真实姓名
  376. // 树维系统会先定义 var actTeachers = [{id:123, name:"张三"}],再用 actTeacherName.join(',') 传参
  377. function resolveTeachersForTaskActivityBlock(fullText, blockStartIndex) {
  378. // 向前搜索最近的 actTeachers 变量定义(一般在前 2000 字符内)
  379. const start = Math.max(0, blockStartIndex - 2200);
  380. const segment = fullText.slice(start, blockStartIndex);
  381. const re = /var\s+actTeachers\s*=\s*\[([^]*?)\]\s*;/g;
  382. let m;
  383. let last = null;
  384. while ((m = re.exec(segment)) !== null) {
  385. last = m[1];
  386. }
  387. if (!last) return "";
  388. const names = [];
  389. const nameRe = /name\s*:\s*(?:"([^"]*)"|'([^']*)')/g;
  390. let nm;
  391. while ((nm = nameRe.exec(last)) !== null) {
  392. const name = (nm[1] || nm[2] || "").trim();
  393. if (name) names.push(name);
  394. }
  395. if (names.length === 0) return "";
  396. return Array.from(new Set(names)).join(",");
  397. }
  398. // 合并同一课程的连续节次
  399. function mergeContiguousSections(courses) {
  400. const list = (courses || [])
  401. .filter((c) => c && c.name && Number.isInteger(c.day) && Number.isInteger(c.startSection) && Number.isInteger(c.endSection))
  402. .map((c) => ({
  403. ...c,
  404. weeks: normalizeWeeks(c.weeks)
  405. }));
  406. list.sort((a, b) => {
  407. const ak = `${a.name}|${a.teacher}|${a.position}|${a.day}|${a.weeks.join(",")}`;
  408. const bk = `${b.name}|${b.teacher}|${b.position}|${b.day}|${b.weeks.join(",")}`;
  409. if (ak < bk) return -1;
  410. if (ak > bk) return 1;
  411. return a.startSection - b.startSection;
  412. });
  413. const merged = [];
  414. for (const item of list) {
  415. const prev = merged[merged.length - 1];
  416. const canMerge = prev
  417. && prev.name === item.name
  418. && prev.teacher === item.teacher
  419. && prev.position === item.position
  420. && prev.day === item.day
  421. && prev.weeks.join(",") === item.weeks.join(",")
  422. && prev.endSection + 1 >= item.startSection;
  423. if (canMerge) {
  424. prev.endSection = Math.max(prev.endSection, item.endSection);
  425. } else {
  426. merged.push({ ...item });
  427. }
  428. }
  429. return merged;
  430. }
  431. function getPresetTimeSlots() {
  432. return [
  433. { number: 1, startTime: "08:00", endTime: "08:45" },
  434. { number: 2, startTime: "10:05", endTime: "11:40" },
  435. { number: 3, startTime: "12:00", endTime: "13:35" }, // 午间课
  436. { number: 4, startTime: "14:00", endTime: "15:35" },
  437. { number: 5, startTime: "16:05", endTime: "17:40" },
  438. { number: 6, startTime: "17:45", endTime: "18:30" }, // 晚间课,部分课程为 18:00-18:45
  439. { number: 7, startTime: "19:00", endTime: "20:35" },
  440. { number: 8, startTime: "20:45", endTime: "22:20" }
  441. ];
  442. }
  443. async function runImportFlow() {
  444. ensureBridgePromise();
  445. recordDiag("start", `base=${BASE}`);
  446. safeToast("开始自动探测长江大学教务参数...");
  447. // 1) 探测学生 ID(ids)和学期选择组件 ID(tagId)
  448. recordDiag("detect_params", "request entry page");
  449. const entryUrl = `${BASE}/eams/courseTableForStd.action?&sf_request_type=ajax`;
  450. const entryHtml = await requestText(entryUrl, {
  451. method: "GET",
  452. headers: { "x-requested-with": "XMLHttpRequest" }
  453. });
  454. const params = parseEntryParams(entryHtml);
  455. recordDiag("detect_params", `studentId=${params.studentId ? "ok" : "missing"}, tagId=${params.tagId ? "ok" : "missing"}`);
  456. if (!params.studentId || !params.tagId) {
  457. await window.AndroidBridgePromise.showAlert(
  458. "参数探测失败",
  459. "未能识别学生 ID 或学期组件 tagId,请确认已登录后重试。",
  460. "确定"
  461. );
  462. return;
  463. }
  464. // 2) 获取学期列表并让用户选择(最近 8 个)
  465. recordDiag("load_semesters", "request semester list");
  466. const semesterRaw = await requestText(`${BASE}/eams/dataQuery.action?sf_request_type=ajax`, {
  467. method: "POST",
  468. headers: { "content-type": "application/x-www-form-urlencoded; charset=UTF-8" },
  469. body: `tagId=${encodeURIComponent(params.tagId)}&dataType=semesterCalendar`
  470. });
  471. const allSemesters = parseSemesterResponse(semesterRaw);
  472. recordDiag("load_semesters", `semesterCount=${allSemesters.length}`);
  473. if (allSemesters.length === 0) {
  474. throw createImportError("load_semesters", "学期列表为空,无法继续导入。", {
  475. responsePreview: truncateText(semesterRaw, MAX_PREVIEW_LEN)
  476. });
  477. }
  478. const recentSemesters = allSemesters.slice(-8);
  479. const selectIndex = await window.AndroidBridgePromise.showSingleSelection(
  480. "请选择导入学期",
  481. JSON.stringify(recentSemesters.map((s) => s.name || s.id)),
  482. recentSemesters.length - 1
  483. );
  484. if (selectIndex === null) {
  485. recordDiag("select_semester", "user cancelled");
  486. safeToast("已取消导入");
  487. return;
  488. }
  489. const selectedSemester = recentSemesters[selectIndex];
  490. recordDiag("select_semester", `selected=${selectedSemester.id}`);
  491. safeToast("正在获取课表数据...");
  492. // 3) 拉取选定学期的课表 HTML
  493. recordDiag("load_courses", "request course table html");
  494. const courseHtml = await requestText(`${BASE}/eams/courseTableForStd!courseTable.action?sf_request_type=ajax`, {
  495. method: "POST",
  496. headers: { "content-type": "application/x-www-form-urlencoded; charset=UTF-8" },
  497. body: [
  498. "ignoreHead=1",
  499. "setting.kind=std",
  500. "startWeek=",
  501. `semester.id=${encodeURIComponent(selectedSemester.id)}`,
  502. `ids=${encodeURIComponent(params.studentId)}`
  503. ].join("&")
  504. });
  505. // 4) 解析课表脚本并保存到应用
  506. const courses = parseCoursesFromTaskActivityScript(courseHtml);
  507. recordDiag("parse_courses", `count=${courses.length}`);
  508. if (courses.length === 0) {
  509. const debugInfo = extractCourseHtmlDebugInfo(courseHtml);
  510. recordDiag("parse_courses", `no-course len=${debugInfo.responseLength}, hasTaskActivity=${debugInfo.hasTaskActivity}`);
  511. window.__IMPORT_DEBUG_LAST_COURSE_HTML = String(courseHtml || "");
  512. console.warn("[课表解析失败]", debugInfo);
  513. await safeShowDetailedError(
  514. "解析失败",
  515. [
  516. "未能从课表响应中识别到课程。",
  517. `响应长度: ${debugInfo.responseLength}`,
  518. `包含 TaskActivity: ${debugInfo.hasTaskActivity}`,
  519. `包含 unitCount: ${debugInfo.hasUnitCount}`,
  520. "",
  521. "[头部预览]",
  522. debugInfo.headPreview,
  523. "",
  524. "[尾部预览]",
  525. debugInfo.tailPreview,
  526. "",
  527. "完整响应: window.__IMPORT_DEBUG_LAST_COURSE_HTML"
  528. ].join("\n")
  529. );
  530. return;
  531. }
  532. recordDiag("save_courses", `count=${courses.length}`);
  533. console.info("[导入结果]", {
  534. courseCount: courses.length,
  535. sample: courses.slice(0, 3)
  536. });
  537. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  538. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(getPresetTimeSlots()));
  539. recordDiag("done", "import success");
  540. safeToast(`导入成功,共 ${courses.length} 条课程`);
  541. AndroidBridge.notifyTaskCompletion();
  542. }
  543. (async function bootstrap() {
  544. try {
  545. await runImportFlow();
  546. } catch (error) {
  547. const normalizedError = (error && error.name === "ImportFlowError")
  548. ? error
  549. : createImportError(diagState.currentStep, error && error.message ? error.message : "未知错误", {}, error);
  550. const details = formatErrorDetails(normalizedError);
  551. console.error("[长江大学教务适配] 导入失败详情:\n" + details);
  552. await safeShowDetailedError("导入失败(详细信息)", details);
  553. safeToast(`导入失败:${normalizedError.message || normalizedError}`);
  554. }
  555. })();
  556. })();