gdouyj2.js 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874
  1. /**
  2. * 广东海洋大学阳江校区教务适配(备用通道)
  3. * @date 2026-9-12
  4. * @author Yihe-ng,海大百事通
  5. * @version 1.0
  6. *
  7. * 教务系统不可用时的备用通道:数据取自教学质量综合评价系统(jxpj.gdou.edu.cn),
  8. * 校内外均可直连,无需 VPN 或校园网。
  9. * 该系统课表与教务同源同步,字段含周次、星期、节次、教室,可直接生成课表。
  10. * 该方法由海大百事通与20260912发现
  11. *
  12. * 接口链路(均为同源请求,复用页面登录态):
  13. * POST /apiservice/University/GetAllSemester 学期列表
  14. * POST /mita/prepare/timetable/setting 学期设置(总周数、当前周、当前周周一)
  15. * POST /mita/online/message/student/course/list 学生课程列表(含教师工号)
  16. * POST /mita/prepare/timetable/timetables 按教师查询全学期排课
  17. *
  18. * 两处报文字段沿用服务端约定(非笔误):学期参数名为 semeter;请求体需加密并追加固定后缀。
  19. */
  20. (function () {
  21. // ==================== 常量 ====================
  22. const AES_KEY = "nfZYwnW2ppQc3CXr";
  23. const AES_SUFFIX = "d^PrEK&c";
  24. const API_SEMESTER_LIST = "/apiservice/University/GetAllSemester";
  25. const API_TIMETABLE_SETTING = "/prepare/timetable/setting";
  26. const API_COURSE_LIST = "/online/message/student/course/list";
  27. const API_TIMETABLE = "/prepare/timetable/timetables";
  28. const DEFAULT_TOTAL_WEEKS = 20;
  29. // 单次请求超时。若服务端不响应,fetch 会一直挂起,流程既无法失败也无法返回。
  30. const REQUEST_TIMEOUT_MS = 20000;
  31. // 阳江校区慎思楼上课时间
  32. const TimeSlots = [
  33. { number: 1, startTime: "08:10", endTime: "08:55" },
  34. { number: 2, startTime: "09:05", endTime: "09:50" },
  35. { number: 3, startTime: "10:20", endTime: "11:05" },
  36. { number: 4, startTime: "11:15", endTime: "12:00" },
  37. { number: 5, startTime: "14:30", endTime: "15:15" },
  38. { number: 6, startTime: "15:20", endTime: "16:05" },
  39. { number: 7, startTime: "16:20", endTime: "17:05" },
  40. { number: 8, startTime: "17:10", endTime: "17:55" },
  41. { number: 9, startTime: "19:30", endTime: "20:15" },
  42. { number: 10, startTime: "20:25", endTime: "21:10" }
  43. ];
  44. /**
  45. * 阳江校区其他场地(非慎思楼)的作息。
  46. * 只有第 3、4 节在校区作息表中是不同的连续时间块,使用自定义时间表示。
  47. * 貌似无法动态获取时间
  48. */
  49. const OTHER_VENUE_SECTION_3_4_TIME = { startTime: "10:10", endTime: "11:40" };
  50. // ==================== AES-128-ECB 加密 ====================
  51. function utf8Bytes(text) {
  52. const bytes = [];
  53. for (let i = 0; i < text.length; i++) {
  54. let code = text.charCodeAt(i);
  55. if (code < 0x80) {
  56. bytes.push(code);
  57. } else if (code < 0x800) {
  58. bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
  59. } else {
  60. bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
  61. }
  62. }
  63. return bytes;
  64. }
  65. function gfMultiply(a, b) {
  66. let product = 0;
  67. for (let i = 0; i < 8; i++) {
  68. if (b & 1) product ^= a;
  69. const highBit = a & 0x80;
  70. a = (a << 1) & 0xff;
  71. if (highBit) a ^= 0x1b;
  72. b >>= 1;
  73. }
  74. return product & 0xff;
  75. }
  76. function gfPower(base, exponent) {
  77. let result = 1;
  78. while (exponent > 0) {
  79. if (exponent & 1) result = gfMultiply(result, base);
  80. base = gfMultiply(base, base);
  81. exponent >>= 1;
  82. }
  83. return result;
  84. }
  85. const S_BOX = (function () {
  86. const box = new Array(256);
  87. for (let i = 0; i < 256; i++) {
  88. const inverse = i === 0 ? 0 : gfPower(i, 254);
  89. let value = 0;
  90. for (let bit = 0; bit < 8; bit++) {
  91. const b = ((inverse >> bit) & 1)
  92. ^ ((inverse >> ((bit + 4) % 8)) & 1)
  93. ^ ((inverse >> ((bit + 5) % 8)) & 1)
  94. ^ ((inverse >> ((bit + 6) % 8)) & 1)
  95. ^ ((inverse >> ((bit + 7) % 8)) & 1)
  96. ^ ((0x63 >> bit) & 1);
  97. value |= b << bit;
  98. }
  99. box[i] = value & 0xff;
  100. }
  101. return box;
  102. })();
  103. function expandKey(keyBytes) {
  104. const words = [];
  105. for (let i = 0; i < 4; i++) {
  106. words[i] = ((keyBytes[4 * i] << 24) | (keyBytes[4 * i + 1] << 16)
  107. | (keyBytes[4 * i + 2] << 8) | keyBytes[4 * i + 3]) >>> 0;
  108. }
  109. let roundConstant = 1;
  110. for (let i = 4; i < 44; i++) {
  111. let temp = words[i - 1];
  112. if (i % 4 === 0) {
  113. temp = ((temp << 8) | (temp >>> 24)) >>> 0;
  114. temp = ((S_BOX[(temp >>> 24) & 0xff] << 24)
  115. | (S_BOX[(temp >>> 16) & 0xff] << 16)
  116. | (S_BOX[(temp >>> 8) & 0xff] << 8)
  117. | S_BOX[temp & 0xff]) >>> 0;
  118. temp = (temp ^ (roundConstant << 24)) >>> 0;
  119. roundConstant = gfMultiply(roundConstant, 2);
  120. }
  121. words[i] = (words[i - 4] ^ temp) >>> 0;
  122. }
  123. return words;
  124. }
  125. function addRoundKey(state, words, round) {
  126. for (let column = 0; column < 4; column++) {
  127. const word = words[4 * round + column];
  128. state[4 * column] ^= (word >>> 24) & 0xff;
  129. state[4 * column + 1] ^= (word >>> 16) & 0xff;
  130. state[4 * column + 2] ^= (word >>> 8) & 0xff;
  131. state[4 * column + 3] ^= word & 0xff;
  132. }
  133. }
  134. function subBytes(state) {
  135. for (let i = 0; i < 16; i++) state[i] = S_BOX[state[i]];
  136. }
  137. function shiftRows(state) {
  138. let temp = state[1];
  139. state[1] = state[5]; state[5] = state[9]; state[9] = state[13]; state[13] = temp;
  140. temp = state[2]; state[2] = state[10]; state[10] = temp;
  141. temp = state[6]; state[6] = state[14]; state[14] = temp;
  142. temp = state[15]; state[15] = state[11]; state[11] = state[7]; state[7] = state[3]; state[3] = temp;
  143. }
  144. function mixColumns(state) {
  145. for (let column = 0; column < 4; column++) {
  146. const i = 4 * column;
  147. const a0 = state[i], a1 = state[i + 1], a2 = state[i + 2], a3 = state[i + 3];
  148. const all = a0 ^ a1 ^ a2 ^ a3;
  149. state[i] = a0 ^ all ^ gfMultiply(a0 ^ a1, 2);
  150. state[i + 1] = a1 ^ all ^ gfMultiply(a1 ^ a2, 2);
  151. state[i + 2] = a2 ^ all ^ gfMultiply(a2 ^ a3, 2);
  152. state[i + 3] = a3 ^ all ^ gfMultiply(a3 ^ a0, 2);
  153. }
  154. }
  155. function encryptBlock(words, input) {
  156. const state = input.slice();
  157. addRoundKey(state, words, 0);
  158. for (let round = 1; round <= 9; round++) {
  159. subBytes(state);
  160. shiftRows(state);
  161. mixColumns(state);
  162. addRoundKey(state, words, round);
  163. }
  164. subBytes(state);
  165. shiftRows(state);
  166. addRoundKey(state, words, 10);
  167. return state;
  168. }
  169. const BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
  170. function bytesToBase64(bytes) {
  171. let output = "";
  172. for (let i = 0; i < bytes.length; i += 3) {
  173. const b0 = bytes[i];
  174. const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
  175. const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
  176. output += BASE64_CHARS[b0 >> 2];
  177. output += BASE64_CHARS[((b0 & 0x03) << 4) | (b1 >> 4)];
  178. output += i + 1 < bytes.length ? BASE64_CHARS[((b1 & 0x0f) << 2) | (b2 >> 6)] : "=";
  179. output += i + 2 < bytes.length ? BASE64_CHARS[b2 & 0x3f] : "=";
  180. }
  181. return output;
  182. }
  183. function encryptPayload(plainText) {
  184. const key = utf8Bytes(AES_KEY);
  185. const data = utf8Bytes(plainText + AES_SUFFIX);
  186. const padLength = 16 - (data.length % 16);
  187. for (let i = 0; i < padLength; i++) data.push(padLength);
  188. const words = expandKey(key);
  189. const output = [];
  190. for (let offset = 0; offset < data.length; offset += 16) {
  191. const block = encryptBlock(words, data.slice(offset, offset + 16));
  192. for (let i = 0; i < 16; i++) output.push(block[i]);
  193. }
  194. return bytesToBase64(output);
  195. }
  196. // ==================== 会话与请求 ====================
  197. function readStorageJson(storage, key) {
  198. try {
  199. return JSON.parse(storage.getItem(key) || "null");
  200. } catch (error) {
  201. return null;
  202. }
  203. }
  204. /**
  205. * 读取登录用户信息。
  206. * 该系统的桌面端将 current-user 存于 localStorage,微信/移动端存于 sessionStorage,
  207. * 应用中打开的是移动端,故两处都必须尝试。
  208. */
  209. function readCurrentUser() {
  210. return readStorageJson(sessionStorage, "current-user")
  211. || readStorageJson(localStorage, "current-user");
  212. }
  213. function readClientId() {
  214. const visitor = readStorageJson(localStorage, "visitorObj");
  215. return visitor && visitor.ClientId ? visitor.ClientId : "";
  216. }
  217. function readCachedSemester() {
  218. const cached = readStorageJson(sessionStorage, "semester")
  219. || readStorageJson(localStorage, "semester");
  220. return cached && cached.key ? String(cached.key) : "";
  221. }
  222. /**
  223. * 解析用户标识。移动端以 realCode 作为实际账号,桌面端仅提供 Code,故需回退。
  224. */
  225. function resolveUserCode(user) {
  226. return String((user && (user.realCode || user.Code)) || "");
  227. }
  228. function formatNow() {
  229. const date = new Date();
  230. const pad = (value) => String(value).padStart(2, "0");
  231. return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
  232. + `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
  233. }
  234. function buildSystemParams(user, clientId, apiName, semester) {
  235. return {
  236. DegreeLevel: 0,
  237. Token: user.Token,
  238. UserCode: resolveUserCode(user),
  239. UniversityCode: user.UniversityCode,
  240. ApiName: apiName,
  241. ClientTime: formatNow(),
  242. ClientId: clientId,
  243. ClientType: 0,
  244. Semester: semester,
  245. RequestOriginPageAddress: window.location.href
  246. };
  247. }
  248. /**
  249. * 带超时的 fetch。旧版 WebView 可能没有 AbortController,此时退化为普通请求。
  250. */
  251. async function fetchWithTimeout(url, options) {
  252. if (typeof AbortController === "undefined") {
  253. return await fetch(url, options);
  254. }
  255. const controller = new AbortController();
  256. const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  257. try {
  258. return await fetch(url, Object.assign({}, options, { signal: controller.signal }));
  259. } finally {
  260. clearTimeout(timer);
  261. }
  262. }
  263. async function postApi(user, clientId, path, apiName, semester, requestParams) {
  264. const payload = {
  265. SystemParams: buildSystemParams(user, clientId, apiName, semester),
  266. RequestParams: requestParams || {}
  267. };
  268. const response = await fetchWithTimeout(path, {
  269. method: "POST",
  270. headers: {
  271. "Content-Type": "application/json; charset=utf-8",
  272. "Accept": "application/json",
  273. "token": user.Token
  274. },
  275. body: encryptPayload(JSON.stringify(payload)),
  276. credentials: "include"
  277. });
  278. if (!response.ok) {
  279. throw new Error(`HTTP ${response.status}`);
  280. }
  281. return await response.json();
  282. }
  283. async function postMita(user, clientId, path, semester, params) {
  284. const userCode = resolveUserCode(user);
  285. const merged = Object.assign(
  286. {
  287. teacherCode: userCode,
  288. teacherName: user.Name,
  289. semeter: semester,
  290. schoolCode: user.UniversityCode,
  291. currentUser: userCode,
  292. Token: user.Token,
  293. ClientId: clientId
  294. },
  295. params || {},
  296. { RequestOriginPageAddress: window.location.href }
  297. );
  298. const response = await fetchWithTimeout("/mita" + path, {
  299. method: "POST",
  300. headers: {
  301. "Content-Type": "application/json",
  302. "token": user.Token
  303. },
  304. body: encryptPayload(JSON.stringify(merged)),
  305. credentials: "include"
  306. });
  307. if (!response.ok) {
  308. throw new Error(`HTTP ${response.status}`);
  309. }
  310. return await response.json();
  311. }
  312. // ==================== 数据转换 ====================
  313. function padTwo(value) {
  314. return String(value).padStart(2, "0");
  315. }
  316. /**
  317. * 由「当前周」与「当前周周一日期」反推学期第一周的周一日期。
  318. * 服务端返回的 firstOfWeek 是当前周的周一,需按已过周数向前回退。
  319. */
  320. function deriveSemesterStartDate(firstOfWeek, currentWeek) {
  321. const text = String(firstOfWeek || "").trim();
  322. const matched = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
  323. if (!matched) return null;
  324. const week = Number(currentWeek);
  325. const offsetDays = Number.isInteger(week) && week > 0 ? (week - 1) * 7 : 0;
  326. const date = new Date(Date.UTC(Number(matched[1]), Number(matched[2]) - 1, Number(matched[3])));
  327. if (isNaN(date.getTime())) return null;
  328. date.setUTCDate(date.getUTCDate() - offsetDays);
  329. return `${date.getUTCFullYear()}-${padTwo(date.getUTCMonth() + 1)}-${padTwo(date.getUTCDate())}`;
  330. }
  331. function getOtherVenueCustomTime(position, startSection, endSection) {
  332. const positionText = position == null ? "" : String(position);
  333. if (!positionText || positionText.includes("慎思楼")) return null;
  334. if (startSection !== 3 || endSection !== 4) return null;
  335. return OTHER_VENUE_SECTION_3_4_TIME;
  336. }
  337. function normalizeWeek(value) {
  338. const week = Number(value);
  339. return Number.isInteger(week) && week > 0 ? week : null;
  340. }
  341. function normalizeSection(value) {
  342. const section = Number(value);
  343. return Number.isInteger(section) && section >= 1 ? section : null;
  344. }
  345. function splitClassNames(value) {
  346. return String(value == null ? "" : value)
  347. .split(/[,,]/)
  348. .map((item) => item.trim())
  349. .filter(Boolean);
  350. }
  351. /**
  352. * 从教师排课记录中筛选属于该学生的课程。
  353. * 评价系统的 classCode 与排课系统可能不同源,故先按 classCode 精确匹配,
  354. * 未命中时退回按班级名称匹配。
  355. */
  356. function pickStudentEntries(entries, course) {
  357. const sameCourse = entries.filter((entry) => entry.courseCode === course.courseCode);
  358. if (sameCourse.length === 0) return [];
  359. const byClassCode = sameCourse.filter((entry) => entry.classCode === course.classCode);
  360. if (byClassCode.length > 0) return byClassCode;
  361. const classNames = splitClassNames(course.className);
  362. if (classNames.length === 0) return [];
  363. return sameCourse.filter((entry) => (entry.reTimetableClasses || []).some((item) =>
  364. splitClassNames(item.className).some((name) => classNames.indexOf(name) !== -1)
  365. ));
  366. }
  367. function toCourseKey(course) {
  368. return [course.name, course.teacher, course.position, course.day,
  369. course.startSection, course.endSection].join("\u0001");
  370. }
  371. /**
  372. * 节次与周次合并去重。
  373. * 取自官方 wiki《课程合并与去重函数》的参考实现,未作改动;
  374. * 用于把服务端可能返回的「逐节」或「分段周次」记录归一为连续区间。
  375. */
  376. function mergeAndDistinctCourses(courses) {
  377. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  378. const list = courses.map(c => ({
  379. ...c,
  380. name: c.name || '',
  381. teacher: c.teacher || '',
  382. position: c.position || '',
  383. weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
  384. }));
  385. list.sort((a, b) =>
  386. a.name.localeCompare(b.name) ||
  387. a.teacher.localeCompare(b.teacher) ||
  388. a.position.localeCompare(b.position) ||
  389. (a.day || 0) - (b.day || 0) ||
  390. a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
  391. (a.startSection || 0) - (b.startSection || 0)
  392. );
  393. const step1Merged = [];
  394. let current = list[0];
  395. for (let i = 1; i < list.length; i++) {
  396. const next = list[i];
  397. const isSameCourseAndWeeks =
  398. current.name === next.name &&
  399. current.teacher === next.teacher &&
  400. current.position === next.position &&
  401. current.day === next.day &&
  402. current.weeks.join(',') === next.weeks.join(',');
  403. const isContinuous = current.endSection + 1 === next.startSection;
  404. const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
  405. if (isSameCourseAndWeeks && isContinuous) {
  406. current.endSection = next.endSection;
  407. } else if (isSameCourseAndWeeks && isDuplicate) {
  408. continue;
  409. } else {
  410. step1Merged.push(current);
  411. current = next;
  412. }
  413. }
  414. step1Merged.push(current);
  415. step1Merged.sort((a, b) =>
  416. a.name.localeCompare(b.name) ||
  417. a.teacher.localeCompare(b.teacher) ||
  418. a.position.localeCompare(b.position) ||
  419. (a.day || 0) - (b.day || 0) ||
  420. (a.startSection || 0) - (b.startSection || 0) ||
  421. (a.endSection || 0) - (b.endSection || 0)
  422. );
  423. const step2Merged = [];
  424. let cur = step1Merged[0];
  425. for (let i = 1; i < step1Merged.length; i++) {
  426. const nxt = step1Merged[i];
  427. const isSameCourseAndSection =
  428. cur.name === nxt.name &&
  429. cur.teacher === nxt.teacher &&
  430. cur.position === nxt.position &&
  431. cur.day === nxt.day &&
  432. cur.startSection === nxt.startSection &&
  433. cur.endSection === nxt.endSection;
  434. if (isSameCourseAndSection) {
  435. cur.weeks = Array.from(new Set([...cur.weeks, ...nxt.weeks])).sort((a, b) => a - b);
  436. } else {
  437. step2Merged.push(cur);
  438. cur = nxt;
  439. }
  440. }
  441. step2Merged.push(cur);
  442. return step2Merged;
  443. }
  444. /**
  445. * 为其他场地第 3-4 节补充自定义时间。
  446. * 必须在合并之后调用:只有先合并成完整的 3-4 节,才满足自定义时间的匹配条件。
  447. */
  448. function applyOtherVenueCustomTime(course) {
  449. const customTime = getOtherVenueCustomTime(
  450. course.position,
  451. course.startSection,
  452. course.endSection
  453. );
  454. if (!customTime) return course;
  455. return Object.assign({}, course, {
  456. isCustomTime: true,
  457. customStartTime: customTime.startTime,
  458. customEndTime: customTime.endTime
  459. });
  460. }
  461. function aggregateCourses(entries, course) {
  462. const merged = new Map();
  463. entries.forEach((entry) => {
  464. const week = normalizeWeek(entry.onweek);
  465. const startSection = normalizeSection(entry.seqStart);
  466. const endSection = normalizeSection(entry.seqEnd);
  467. const day = normalizeSection(entry.dayOfWeek);
  468. if (week === null || startSection === null || endSection === null || day === null) return;
  469. if (day < 1 || day > 7 || endSection < startSection) return;
  470. const position = entry.address == null ? "" : String(entry.address).trim();
  471. const item = {
  472. name: String(entry.courseName || course.courseName || "").trim(),
  473. teacher: String(entry.teacherName || course.teacherName || "").trim(),
  474. position: position,
  475. day: day,
  476. startSection: startSection,
  477. endSection: endSection
  478. };
  479. const key = toCourseKey(item);
  480. if (!merged.has(key)) {
  481. merged.set(key, Object.assign({}, item, { weeks: [] }));
  482. }
  483. const target = merged.get(key);
  484. if (target.weeks.indexOf(week) === -1) target.weeks.push(week);
  485. });
  486. return mergeAndDistinctCourses(Array.from(merged.values()))
  487. .map(applyOtherVenueCustomTime);
  488. }
  489. function sortCourses(courses) {
  490. return courses.sort((a, b) =>
  491. a.day - b.day
  492. || a.startSection - b.startSection
  493. || a.name.localeCompare(b.name, "zh-Hans-CN")
  494. );
  495. }
  496. // ==================== 数据获取 ====================
  497. function describeError(error) {
  498. return error && error.message ? error.message : String(error);
  499. }
  500. async function fetchSemesters(user, clientId) {
  501. const data = await postApi(user, clientId, API_SEMESTER_LIST,
  502. "University/GetAllSemester", user.CurrentSemester, {});
  503. if (!data || data.Success !== true || !Array.isArray(data.Value)) return [];
  504. return data.Value
  505. .map((item) => ({
  506. key: String(item.key || "").trim(),
  507. name: String(item.name || item.key || "").trim(),
  508. selected: item.selected === true
  509. }))
  510. .filter((item) => item.key);
  511. }
  512. async function fetchSemesterSetting(user, clientId, semester) {
  513. try {
  514. const data = await postMita(user, clientId, API_TIMETABLE_SETTING, semester, {});
  515. if (!data || data.success !== true || !data.result) return null;
  516. return {
  517. currentWeek: Number(data.result.currentWeek) || null,
  518. firstOfWeek: data.result.firstOfWeek || null
  519. };
  520. } catch (error) {
  521. console.warn("JS: 读取学期设置失败:", error);
  522. return null;
  523. }
  524. }
  525. async function fetchCourseList(user, clientId, semester) {
  526. const data = await postMita(user, clientId, API_COURSE_LIST, semester, {
  527. pageNum: "1",
  528. pageSize: "999",
  529. studentCode: resolveUserCode(user),
  530. isAdmin: "0"
  531. });
  532. if (!data || data.success !== true || !data.result) return [];
  533. return Array.isArray(data.result.result) ? data.result.result : [];
  534. }
  535. async function fetchTeacherTimetable(user, clientId, semester, teacherCode) {
  536. const data = await postMita(user, clientId, API_TIMETABLE, semester, {
  537. onweek: null,
  538. teacherCode: teacherCode
  539. });
  540. if (!data || data.success !== true) return [];
  541. return Array.isArray(data.result) ? data.result : [];
  542. }
  543. async function collectCourses(user, clientId, semester) {
  544. const courseList = await fetchCourseList(user, clientId, semester);
  545. if (courseList.length === 0) return null;
  546. const byTeacher = new Map();
  547. courseList.forEach((course) => {
  548. const teacherCode = String(course.teacherCode || "").trim();
  549. if (!teacherCode) return;
  550. if (!byTeacher.has(teacherCode)) byTeacher.set(teacherCode, []);
  551. byTeacher.get(teacherCode).push(course);
  552. });
  553. const teacherCodes = Array.from(byTeacher.keys());
  554. const timetables = await Promise.all(teacherCodes.map((teacherCode) =>
  555. fetchTeacherTimetable(user, clientId, semester, teacherCode)
  556. .catch((error) => {
  557. console.warn(`JS: 读取教师 ${teacherCode} 排课失败:`, error);
  558. return [];
  559. })
  560. ));
  561. const courses = [];
  562. teacherCodes.forEach((teacherCode, index) => {
  563. const entries = timetables[index];
  564. if (!entries.length) return;
  565. byTeacher.get(teacherCode).forEach((course) => {
  566. const picked = pickStudentEntries(entries, course);
  567. if (picked.length === 0) return;
  568. courses.push.apply(courses, aggregateCourses(picked, course));
  569. });
  570. });
  571. return sortCourses(courses);
  572. }
  573. // ==================== 用户交互 ====================
  574. function showToast(message) {
  575. window.shiguangBridge.showToast(message);
  576. }
  577. async function promptUserToStart() {
  578. return await window.shiguangBridgePromise.showAlert(
  579. "广东海洋大学阳江校区课表导入(备用通道)",
  580. "本适配器通过接口直接获取课表,无需停留在课表页面。\n\n"
  581. + "导入步骤:\n"
  582. + "1. 确认已登录教学质量综合评价系统(未登录请先完成统一认证)\n"
  583. + "2. 停留在任意页面即可,无需切换\n"
  584. + "3. 点击「执行导入」\n\n"
  585. + "说明:数据取自教学评价系统,部分课程可能缺失,可手动补录。",
  586. "我已登录,开始导入"
  587. );
  588. }
  589. async function promptUserToLogin() {
  590. return await window.shiguangBridgePromise.showAlert(
  591. "需要先登录",
  592. "未检测到登录状态,请先在本页面完成登录:\n\n"
  593. + "1. 页面若显示登录框,输入账号密码登录\n"
  594. + "2. 若已登录但提示本消息,请稍候几秒后重新点击「执行导入」\n\n"
  595. + "登录成功后无需切换页面,直接点击「执行导入」即可。",
  596. "我知道了"
  597. );
  598. }
  599. async function selectSemester(user, clientId) {
  600. let semesters = [];
  601. try {
  602. semesters = await fetchSemesters(user, clientId);
  603. } catch (error) {
  604. console.warn("JS: 读取学期列表失败:", error);
  605. }
  606. const fallback = String(user.CurrentSemester || "").trim() || readCachedSemester();
  607. if (semesters.length === 0) {
  608. if (!fallback) return null;
  609. return { key: fallback, name: fallback };
  610. }
  611. const labels = semesters.map((item) => item.name);
  612. let defaultIndex = semesters.findIndex((item) => item.selected);
  613. if (defaultIndex === -1) defaultIndex = semesters.findIndex((item) => item.key === fallback);
  614. if (defaultIndex === -1) defaultIndex = 0;
  615. const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
  616. "选择学期",
  617. JSON.stringify(labels),
  618. defaultIndex
  619. );
  620. if (selectedIndex === null || selectedIndex === -1 || !semesters[selectedIndex]) return null;
  621. return semesters[selectedIndex];
  622. }
  623. async function saveCourseConfig(config) {
  624. try {
  625. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  626. return true;
  627. } catch (error) {
  628. showToast(`课表配置保存失败: ${describeError(error)}`);
  629. return false;
  630. }
  631. }
  632. async function saveCourses(courses) {
  633. try {
  634. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  635. return true;
  636. } catch (error) {
  637. showToast(`课程保存失败: ${describeError(error)}`);
  638. return false;
  639. }
  640. }
  641. async function importPresetTimeSlots() {
  642. if (TimeSlots.length === 0) return;
  643. try {
  644. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(TimeSlots));
  645. } catch (error) {
  646. console.warn("JS: 预设时间段导入失败:", error);
  647. showToast("预设时间段导入失败,可稍后手动设置。");
  648. }
  649. }
  650. // ==================== 流程编排 ====================
  651. /**
  652. * 识别是否停留在统一认证相关页面。
  653. * 学校统一认证存在缺陷:认证完成后偶发不回跳到本应用,而落到统一认证自身的页面,
  654. * 此时登录其实已成功,但本应用读不到登录态,需引导用户退回后再导入。
  655. *
  656. * 认证平台对桌面端与移动端提供不同页面,两者都要覆盖:
  657. * 个人信息中心 桌面 /personalInfo/personCenter/,移动 /personalInfo/personalMobile/
  658. * 密码找回页面 桌面 /retrieve-password/retrievePassword/,移动 /retrieve-password/passwordMobile/
  659. * 故按目录前缀匹配而非具体页面名。
  660. *
  661. * 各路径验证程度:
  662. * 个人信息中心 移动版路径由实机反馈确认;桌面版复现路径为
  663. * 已认证 + 无 service 访问认证入口 → /authserver/index.do → /personalInfo/personCenter/。
  664. * 密码找回页面 移动版 /retrieve-password/passwordMobile/ 实测渲染「找回密码」,
  665. * 且移动版认证页的「忘记密码」链接正指向该地址并追加 service 参数。
  666. *
  667. * 判定依据为 URL 路径:登录态存于评价系统域,认证域与之跨域、读不到,只能靠当前地址判断。
  668. */
  669. function detectAuthPage() {
  670. const href = String(window.location.href);
  671. if (href.indexOf("/personalInfo/") !== -1) return "个人信息中心";
  672. if (href.indexOf("/retrieve-password/") !== -1) return "密码找回页面";
  673. if (href.indexOf("authserver.") !== -1) return "统一身份认证页面";
  674. return "";
  675. }
  676. async function promptWrongPage(pageName) {
  677. return await window.shiguangBridgePromise.showAlert(
  678. "请先返回评价系统",
  679. `当前停留在「${pageName}」,无法获取课表数据。\n\n`
  680. + "现象说明:学校统一认证平台在认证完成后,偶尔会跳转到自身的个人信息中心或密码找回页面,"
  681. + "此时登录其实已经成功,只是没有回到应用。\n\n"
  682. + "处理方式:点击左上角「返回」,回到教学质量综合评价系统页面后,再点击「执行导入」即可。",
  683. "我知道了"
  684. );
  685. }
  686. async function runImportFlow() {
  687. const authPage = detectAuthPage();
  688. if (authPage) {
  689. await promptWrongPage(authPage);
  690. return;
  691. }
  692. const user = readCurrentUser();
  693. if (!user || !user.Token) {
  694. await promptUserToLogin();
  695. return;
  696. }
  697. const alertConfirmed = await promptUserToStart();
  698. if (!alertConfirmed) {
  699. showToast("用户取消了导入。");
  700. return;
  701. }
  702. const clientId = readClientId();
  703. const semester = await selectSemester(user, clientId);
  704. if (!semester) {
  705. showToast("导入已取消。");
  706. return;
  707. }
  708. showToast("正在获取课程数据...");
  709. let courses = null;
  710. try {
  711. courses = await collectCourses(user, clientId, semester.key);
  712. } catch (error) {
  713. showToast(`获取课表失败: ${describeError(error)}`);
  714. return;
  715. }
  716. if (courses === null) {
  717. showToast("未查询到课程列表,请确认该学期有选课记录。");
  718. return;
  719. }
  720. if (courses.length === 0) {
  721. showToast("未匹配到任何排课记录,可尝试切换其他学期。");
  722. return;
  723. }
  724. const setting = await fetchSemesterSetting(user, clientId, semester.key);
  725. const config = {
  726. semesterTotalWeeks: DEFAULT_TOTAL_WEEKS,
  727. defaultClassDuration: 45,
  728. defaultBreakDuration: 10,
  729. firstDayOfWeek: 1
  730. };
  731. if (setting) {
  732. const startDate = deriveSemesterStartDate(setting.firstOfWeek, setting.currentWeek);
  733. if (startDate) config.semesterStartDate = startDate;
  734. }
  735. const configSaved = await saveCourseConfig(config);
  736. if (!configSaved) return;
  737. const coursesSaved = await saveCourses(courses);
  738. if (!coursesSaved) return;
  739. await importPresetTimeSlots();
  740. const tip = courses.length === 1 ? "1 门课程" : `${courses.length} 个课程时段`;
  741. showToast(`导入完成,共 ${tip}。部分课程可能缺失,可手动补录。`);
  742. window.shiguangBridge.notifyTaskCompletion();
  743. }
  744. runImportFlow();
  745. })();