cqie_01.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. // 文件: cqie_01.js
  2. // 重庆工程学院(cqie.edu.cn)拾光课程表适配脚本
  3. // 适配新教务系统(njw.cqie.edu.cn),登录门户 i.cqie.edu.cn 后进入教务系统页面执行导入
  4. // 导入前可选择学期,默认最新学期
  5. // 出现问题请提交 pr 更改,这更加快速
  6. // 隔离所有声明,允许脚本在同一 WebView 中失败或取消后再次执行
  7. (async () => {
  8. const HOST = 'njw.cqie.edu.cn';
  9. const RESOURCE_API = 'https://njw.cqie.edu.cn/api/resourceapi';
  10. const TIMETABLE_API = 'https://njw.cqie.edu.cn/api/timetable';
  11. const USER_API = 'https://njw.cqie.edu.cn/api/userserver';
  12. const AUTH_API = 'https://njw.cqie.edu.cn/authserver';
  13. // 注意:本校新教务平台的存储前缀沿用厂商默认的 "cqu_edu_"
  14. // 读取前端存储的访问令牌(兼容 localStorage / sessionStorage,去除引号)
  15. function getAccessToken() {
  16. const keys = ['cqu_edu_ACCESS_TOKEN', 'cqu_edu_CURRENT_TOKEN'];
  17. for (const store of [window.localStorage, window.sessionStorage]) {
  18. for (const key of keys) {
  19. const value = store.getItem(key);
  20. if (value) return value.replaceAll('"', '');
  21. }
  22. }
  23. return null;
  24. }
  25. // 从前端缓存的用户信息中读取学号
  26. // 缓存位置:sessionStorage 裸键 "USER_INFO"(登录服务写入)及各存储中 "*_USER_INFO" 键
  27. function getCachedStudentId() {
  28. for (const store of [window.localStorage, window.sessionStorage]) {
  29. for (const key of Object.keys(store)) {
  30. if (key !== 'USER_INFO' && !key.endsWith('_USER_INFO')) continue;
  31. try {
  32. const info = JSON.parse(store.getItem(key));
  33. if (!info || typeof info !== 'object') continue;
  34. const sid = info.code ?? info.userCode;
  35. if (isPlausibleStudentId(sid)) return String(sid);
  36. } catch (e) { /* 缓存内容不是合法 JSON,跳过 */ }
  37. }
  38. }
  39. return null;
  40. }
  41. // 过滤掉疑似响应状态码的值(200/404 等),避免把接口包装层的 code 误认成学号
  42. function isPlausibleStudentId(value) {
  43. if (value === null || value === undefined) return false;
  44. const text = String(value).trim();
  45. if (text.length < 4) return false;
  46. return !['0', '200', '401', '403', '404', '500'].includes(text);
  47. }
  48. // 通过登录服务的用户信息接口获取学号(与前端 simple-user 接口一致)
  49. async function fetchRemoteStudentId() {
  50. const json = await apiFetch(`${AUTH_API}/simple-user`, {}, "获取学号");
  51. for (const info of [json, unwrap(json)]) {
  52. if (!info || typeof info !== 'object') continue;
  53. const sid = info.code ?? info.userCode ?? info.account;
  54. if (isPlausibleStudentId(sid)) return String(sid);
  55. }
  56. return null;
  57. }
  58. // 学号获取:缓存 -> 登录服务接口 -> 手动输入兜底
  59. async function getStudentId() {
  60. const cached = getCachedStudentId();
  61. if (cached) return cached;
  62. try {
  63. const remote = await fetchRemoteStudentId();
  64. if (remote) return remote;
  65. } catch (e) {
  66. console.warn("获取学号接口失败:", e);
  67. }
  68. return await promptStudentId();
  69. }
  70. // 学号输入校验(供 showPrompt 按名回调使用;桥接在页面全局作用域查找函数,必须挂到 window 上)
  71. window.validateStudentId = function (studentId) {
  72. if (studentId === null || studentId.trim().length === 0) {
  73. return "学号不能为空!";
  74. }
  75. return false;
  76. };
  77. // 兜底:通过弹窗让用户手动输入学号
  78. async function promptStudentId() {
  79. const input = await window.shiguangBridgePromise.showPrompt(
  80. "输入学号",
  81. "未能自动识别学号,请输入你的学号:",
  82. "",
  83. "validateStudentId"
  84. );
  85. if (input === null) throw new Error("用户取消了学号输入");
  86. return input.trim();
  87. }
  88. // 通用请求封装,携带登录令牌;响应体兼容 JSON/空体/非 JSON(如网关异常页)
  89. async function apiFetch(url, options = {}, description = "数据") {
  90. const response = await fetch(url, {
  91. ...options,
  92. credentials: 'include',
  93. headers: {
  94. 'Content-Type': 'application/json',
  95. 'Authorization': `Bearer ${getAccessToken()}`,
  96. ...(options.headers || {}),
  97. },
  98. });
  99. if (response.status === 401 || response.status === 403) {
  100. window.shiguangBridge.showToast(`${description}失败,登录已失效,请退出重试`);
  101. throw new Error(`${description}失败: 登录状态失效 (${response.status})`);
  102. }
  103. if (!response.ok) {
  104. window.shiguangBridge.showToast(`${description}失败,请稍后重试`);
  105. throw new Error(`${description}失败: ${response.status} ${response.statusText}`);
  106. }
  107. const text = await response.text();
  108. if (!text || !text.trim()) return null;
  109. try {
  110. return JSON.parse(text);
  111. } catch (e) {
  112. console.warn(`${description}:响应不是 JSON(前 120 字符):`, text.slice(0, 120));
  113. return { raw: text };
  114. }
  115. }
  116. // 兼容两种响应结构:{code, data: {...}} 或扁平结构
  117. const unwrap = (json) => (json && typeof json.data !== 'undefined' ? json.data : json);
  118. // 获取学期列表(含系统当前学期 ID)
  119. // 优先取"已发布课表"的学期(前端课表页的选择器即用此接口),避免列出全校全量学期;
  120. // 该接口失败时回退到 info-detail 的 sessionFinder 全量列表。
  121. // session/list 仅用于补充开始日期(排序用),失败不影响流程。
  122. async function getSessionInfo() {
  123. const json = await apiFetch(`${RESOURCE_API}/session/info-detail`, {}, "获取学期信息");
  124. const data = unwrap(json) || {};
  125. let sessions = [];
  126. try {
  127. const releaseJson = await apiFetch(`${TIMETABLE_API}/optionFinder/session-release-schedule`, {}, "获取已发布课表的学期");
  128. const release = unwrap(releaseJson) ?? [];
  129. if (Array.isArray(release) && release.length > 0) {
  130. sessions = release
  131. .filter((session) => session && session.id != null)
  132. .map((session) => ({ id: String(session.id), name: session.name ?? String(session.id), beginDate: session.beginDate ?? null }));
  133. }
  134. } catch (e) {
  135. console.warn("获取已发布课表学期失败,回退全量学期列表:", e);
  136. }
  137. if (sessions.length === 0) {
  138. sessions = (json.sessionFinder ?? data.sessionFinder ?? [])
  139. .filter((session) => session && session.id != null && session.id !== '')
  140. .map((session) => ({ id: String(session.id), name: session.name ?? String(session.id), beginDate: null }));
  141. }
  142. try {
  143. const listJson = await apiFetch(`${RESOURCE_API}/session/list`, {}, "获取学期列表");
  144. const list = unwrap(listJson)?.sessionVOList ?? [];
  145. if (list.length > 0) {
  146. const byId = new Map(list.map((session) => [String(session.id), session]));
  147. sessions = sessions.map((session) => ({ ...session, beginDate: session.beginDate ?? byId.get(session.id)?.beginDate ?? null }));
  148. }
  149. } catch (e) {
  150. console.warn("获取学期列表失败,将按学期名称排序:", e);
  151. }
  152. const curSessionId = json.curSessionId ?? data.curSessionId ?? data.id ?? json.id;
  153. return { curSessionId: curSessionId == null ? null : String(curSessionId), sessions };
  154. }
  155. // 学期排序键(数值越大越新):优先学期开始日期(兼容带时间的日期串),
  156. // 否则解析名称中的学年与学期序号(如 "2025-2026学年第二学期"、"2026秋")
  157. const TERM_NUM = { "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "春": 1, "夏": 2, "秋": 3, "冬": 4 };
  158. function sessionSortKey(session) {
  159. const dateMatch = session.beginDate ? String(session.beginDate).match(/^(\d{4})-(\d{2})-(\d{2})/) : null;
  160. if (dateMatch) return Number(dateMatch[1] + dateMatch[2] + dateMatch[3]);
  161. const name = session.name || "";
  162. const year = Number((name.match(/\d{4}/) ?? ["0"])[0]);
  163. const termMatch = name.match(/第([一二三四五六七八\d]+)学期|([春夏秋冬])/);
  164. let term = 0;
  165. if (termMatch) {
  166. term = termMatch[1] !== undefined
  167. ? (TERM_NUM[termMatch[1]] ?? parseInt(termMatch[1], 10) ?? 0)
  168. : (TERM_NUM[termMatch[2]] ?? 0);
  169. }
  170. return year * 100 + term;
  171. }
  172. // 弹窗选择学期,返回选中学期;列表已按新到旧排序,默认选中第一项(最新学期)
  173. async function selectSession(sessions, curSessionId) {
  174. const labels = sessions.map((session) =>
  175. session.id === curSessionId ? `${session.name}(当前学期)` : session.name
  176. );
  177. const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
  178. "选择要导入的学期",
  179. JSON.stringify(labels),
  180. 0
  181. );
  182. if (selectedIndex === null || selectedIndex < 0 || selectedIndex >= sessions.length) {
  183. return null; // 用户取消
  184. }
  185. return sessions[selectedIndex];
  186. }
  187. // 切换服务端当前学期:课表接口按服务端会话学期返回数据,忽略 sessionId 参数。
  188. // 注意:前端调用此接口用的是普通 post(表单格式)而非 postJson(JSON),
  189. // body 必须是 urlencoded 的 curSessionId,发 JSON 会被后端静默忽略(返回 200 但不切换)
  190. async function switchSession(sessionId) {
  191. const json = await apiFetch(`${USER_API}/user-switch-session`, {
  192. method: 'POST',
  193. body: `curSessionId=${encodeURIComponent(sessionId)}`,
  194. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  195. }, "切换学期");
  196. if (json && json.status === 'error') {
  197. window.shiguangBridge.showToast(`切换学期失败(${json.msg ?? '未知错误'}),请重试`);
  198. throw new Error(`切换学期失败: ${json.msg ?? '未知错误'}`);
  199. }
  200. }
  201. // 获取学期开始日期(规范化为 yyyy-MM-dd,App 无法解析带时间的日期串)
  202. async function getStartDate(sessionId) {
  203. const json = await apiFetch(`${RESOURCE_API}/session/detail/${sessionId}`, {}, "获取学期起止信息");
  204. const data = unwrap(json) || {};
  205. const raw = data.beginDate ?? json.beginDate ?? null;
  206. const matched = raw ? String(raw).match(/^(\d{4}-\d{2}-\d{2})/) : null;
  207. return matched ? matched[1] : null;
  208. }
  209. // 获取学期最大周数
  210. async function getMaxWeek(sessionId) {
  211. const json = await apiFetch(`${TIMETABLE_API}/course/maxWeek/${sessionId}`, {}, "获取最大周数");
  212. const maxWeek = Number(unwrap(json));
  213. return Number.isFinite(maxWeek) && maxWeek > 0 ? maxWeek : null;
  214. }
  215. // 获取大节时间配置(periodList 内字段为 smallPeriod/startTime/endTime)
  216. async function getTimeSlots() {
  217. const json = await apiFetch(`${RESOURCE_API}/timePattern/get-large-period`, {}, "获取时间段配置");
  218. const groups = unwrap(json) || [];
  219. let periods = (groups[0] && groups[0].periodList) || [];
  220. if (periods.length === 0 && groups.length > 0) {
  221. periods = groups.flatMap((group) => group.periodList || []);
  222. }
  223. return periods.map((period, index) => ({
  224. number: period.smallPeriod ?? index + 1,
  225. startTime: period.startTime ?? '',
  226. endTime: period.endTime ?? '',
  227. }));
  228. }
  229. // 获取指定学期、指定学号的课表原始数据
  230. // 优先 stu/schedule-detail(我的课表页使用),为空或失败时回退 student/my-table-detail,
  231. // 两个接口参数与返回结构一致(classTimetableVOList)
  232. async function fetchScheduleList(url, studentId, description) {
  233. const json = await apiFetch(url, { method: 'POST', body: JSON.stringify([studentId]) }, description);
  234. const payload = unwrap(json) || {};
  235. return payload.classTimetableVOList ?? json.classTimetableVOList ?? [];
  236. }
  237. async function getSchedule(sessionId, studentId) {
  238. const primary = `${TIMETABLE_API}/class/timetable/stu/schedule-detail?sessionId=${sessionId}`;
  239. const fallback = `${TIMETABLE_API}/class/timetable/student/my-table-detail?sessionId=${sessionId}`;
  240. try {
  241. const list = await fetchScheduleList(primary, studentId, "获取课程表");
  242. if (list.length > 0) return list;
  243. console.warn("stu/schedule-detail 返回为空,尝试备用接口");
  244. } catch (e) {
  245. console.warn("stu/schedule-detail 查询失败,尝试备用接口:", e);
  246. }
  247. return await fetchScheduleList(fallback, studentId, "获取课程表(备用)");
  248. }
  249. // 解析周次字符串,与前端逻辑一致:支持 "1-16"、"1,3,5"、"1-8,10-16" 等
  250. function parseWeeks(weekFormat) {
  251. const weeks = [];
  252. if (!weekFormat) return weeks;
  253. const parts = String(weekFormat).replace(/,/g, ',').split(',');
  254. for (const part of parts) {
  255. const range = part.split('-');
  256. const start = parseInt(range[0], 10);
  257. const end = parseInt(range[1], 10);
  258. if (range.length > 1) {
  259. if (isNaN(start) || isNaN(end)) continue;
  260. const [from, to] = start > end ? [end, start] : [start, end];
  261. for (let week = from; week <= to; week++) {
  262. if (week > 0) weeks.push(week);
  263. }
  264. } else if (range[0] !== '' && !isNaN(start) && start > 0) {
  265. weeks.push(start);
  266. }
  267. }
  268. return [...new Set(weeks)].sort((a, b) => a - b);
  269. }
  270. // 兼容旧版二进制周次串(如 "101010...")
  271. function parseBinaryWeeks(binary) {
  272. if (typeof binary !== 'string' || !/^[01]+$/.test(binary)) return [];
  273. return [...binary].reduce((weeks, char, index) => {
  274. if (char === '1') weeks.push(index + 1);
  275. return weeks;
  276. }, []);
  277. }
  278. // 解析节次字符串(如 "1-2" 或 "3")
  279. function parsePeriodFormat(periodFormat) {
  280. if (!periodFormat) return null;
  281. const parts = String(periodFormat).trim().split('-');
  282. const start = parseInt(parts[0], 10);
  283. const end = parts.length > 1 ? parseInt(parts[1], 10) : start;
  284. if (isNaN(start) || isNaN(end)) return null;
  285. return { start, end };
  286. }
  287. // 清理教师名:去掉工号与 "[主讲];" 等标记(如 "罗桓-03068[主讲];" -> "罗桓")
  288. function cleanTeacher(raw) {
  289. const text = String(raw ?? '').trim();
  290. if (!text) return '';
  291. const names = text.split(/[;,,;]/)
  292. .map((part) => part.replace(/\[[^\]]*\]/g, '').replace(/-\d+$/, '').trim())
  293. .filter(Boolean);
  294. return names.length > 0 ? names.join(',') : text;
  295. }
  296. // 将课表数据转换为拾光课程表格式
  297. function parseSchedule(schedule) {
  298. const courses = [];
  299. for (const item of schedule) {
  300. if (!item || !item.courseName) continue;
  301. const period = parsePeriodFormat(item.periodFormat);
  302. let weeks = parseWeeks(item.teachingWeekFormat);
  303. if (weeks.length === 0) weeks = parseBinaryWeeks(item.teachingWeek);
  304. if (!period || weeks.length === 0) continue;
  305. const day = Number(item.weekDay) || 0;
  306. if (day < 1 || day > 7) continue;
  307. const [startSection, endSection] = period.start <= period.end
  308. ? [period.start, period.end]
  309. : [period.end, period.start];
  310. courses.push({
  311. name: item.courseName,
  312. teacher: cleanTeacher(item.instructorName),
  313. position: item.position || item.roomName || '',
  314. day,
  315. startSection,
  316. endSection,
  317. weeks,
  318. });
  319. }
  320. return courses;
  321. }
  322. // 保存课表数据到拾光课程表
  323. async function saveSchedule(parsedSchedule) {
  324. const tasks = [];
  325. const config = {};
  326. if (parsedSchedule.startDate) config.semesterStartDate = parsedSchedule.startDate;
  327. if (parsedSchedule.maxWeek) config.semesterTotalWeeks = parsedSchedule.maxWeek;
  328. if (Object.keys(config).length > 0) {
  329. tasks.push(window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config)));
  330. }
  331. if (parsedSchedule.timeSlots.length > 0) {
  332. tasks.push(window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(parsedSchedule.timeSlots)));
  333. }
  334. tasks.push(window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedSchedule.courses)));
  335. const results = await Promise.allSettled(tasks);
  336. const failed = results.filter((result) => result.status === 'rejected');
  337. if (failed.length > 0) {
  338. console.error("保存过程出现错误:", failed.map((result) => result.reason));
  339. window.shiguangBridge.showToast("部分数据保存失败,请查看日志后重试");
  340. return false;
  341. }
  342. return true;
  343. }
  344. async function main() {
  345. if (window.location.hostname !== HOST) {
  346. window.shiguangBridge.showToast("请先登录并进入重庆工程学院新教务系统页面,再执行导入!");
  347. return;
  348. }
  349. if (!getAccessToken()) {
  350. window.shiguangBridge.showToast("尚未登录新教务系统,请先登录!");
  351. throw new Error("未检测到登录状态");
  352. }
  353. // 获取学期列表,按新到旧排序后弹窗选择,默认最新学期
  354. const { curSessionId, sessions } = await getSessionInfo();
  355. if (sessions.length === 0) {
  356. window.shiguangBridge.showToast("未获取到学期列表,请稍后重试");
  357. throw new Error("学期列表为空");
  358. }
  359. sessions.sort((a, b) => sessionSortKey(b) - sessionSortKey(a));
  360. const chosen = await selectSession(sessions, curSessionId);
  361. if (!chosen) return;
  362. const confirmed = await window.shiguangBridgePromise.showAlert(
  363. "教务系统课表导入",
  364. `将导入「${chosen.name}」学期的课表`,
  365. "好的,开始导入"
  366. );
  367. if (!confirmed) return;
  368. // 先切换服务端当前学期,否则课表接口会返回系统默认学期的数据
  369. await switchSession(chosen.id);
  370. const studentId = await getStudentId();
  371. const sessionId = chosen.id;
  372. const [startDate, maxWeek, timeSlots, schedule] = await Promise.all([
  373. getStartDate(sessionId),
  374. getMaxWeek(sessionId),
  375. getTimeSlots(),
  376. getSchedule(sessionId, studentId),
  377. ]);
  378. const courses = parseSchedule(schedule);
  379. if (courses.length === 0) {
  380. window.shiguangBridge.showToast("未解析到课程数据,请确认所选学期有课表后再试");
  381. throw new Error("课表数据为空");
  382. }
  383. const success = await saveSchedule({ startDate, maxWeek, timeSlots, courses });
  384. if (success) {
  385. window.shiguangBridge.showToast(`「${chosen.name}」导入成功,共 ${courses.length} 条课程记录!`);
  386. window.shiguangBridge.notifyTaskCompletion();
  387. }
  388. }
  389. // 兜底捕获所有未处理异常,避免导入失败时无任何提示
  390. main().catch((e) => {
  391. console.error("导入过程出现错误:", e);
  392. try {
  393. window.shiguangBridge.showToast(`导入失败:${e?.message ?? e}`);
  394. } catch (_) { /* 桥不可用时仅记录日志 */ }
  395. });
  396. })();