| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430 |
- // 文件: cqie_01.js
- // 重庆工程学院(cqie.edu.cn)拾光课程表适配脚本
- // 适配新教务系统(njw.cqie.edu.cn),登录门户 i.cqie.edu.cn 后进入教务系统页面执行导入
- // 导入前可选择学期,默认最新学期
- // 出现问题请提交 pr 更改,这更加快速
- // 隔离所有声明,允许脚本在同一 WebView 中失败或取消后再次执行
- (async () => {
- const HOST = 'njw.cqie.edu.cn';
- const RESOURCE_API = 'https://njw.cqie.edu.cn/api/resourceapi';
- const TIMETABLE_API = 'https://njw.cqie.edu.cn/api/timetable';
- const USER_API = 'https://njw.cqie.edu.cn/api/userserver';
- const AUTH_API = 'https://njw.cqie.edu.cn/authserver';
- // 注意:本校新教务平台的存储前缀沿用厂商默认的 "cqu_edu_"
- // 读取前端存储的访问令牌(兼容 localStorage / sessionStorage,去除引号)
- function getAccessToken() {
- const keys = ['cqu_edu_ACCESS_TOKEN', 'cqu_edu_CURRENT_TOKEN'];
- for (const store of [window.localStorage, window.sessionStorage]) {
- for (const key of keys) {
- const value = store.getItem(key);
- if (value) return value.replaceAll('"', '');
- }
- }
- return null;
- }
- // 从前端缓存的用户信息中读取学号
- // 缓存位置:sessionStorage 裸键 "USER_INFO"(登录服务写入)及各存储中 "*_USER_INFO" 键
- function getCachedStudentId() {
- for (const store of [window.localStorage, window.sessionStorage]) {
- for (const key of Object.keys(store)) {
- if (key !== 'USER_INFO' && !key.endsWith('_USER_INFO')) continue;
- try {
- const info = JSON.parse(store.getItem(key));
- if (!info || typeof info !== 'object') continue;
- const sid = info.code ?? info.userCode;
- if (isPlausibleStudentId(sid)) return String(sid);
- } catch (e) { /* 缓存内容不是合法 JSON,跳过 */ }
- }
- }
- return null;
- }
- // 过滤掉疑似响应状态码的值(200/404 等),避免把接口包装层的 code 误认成学号
- function isPlausibleStudentId(value) {
- if (value === null || value === undefined) return false;
- const text = String(value).trim();
- if (text.length < 4) return false;
- return !['0', '200', '401', '403', '404', '500'].includes(text);
- }
- // 通过登录服务的用户信息接口获取学号(与前端 simple-user 接口一致)
- async function fetchRemoteStudentId() {
- const json = await apiFetch(`${AUTH_API}/simple-user`, {}, "获取学号");
- for (const info of [json, unwrap(json)]) {
- if (!info || typeof info !== 'object') continue;
- const sid = info.code ?? info.userCode ?? info.account;
- if (isPlausibleStudentId(sid)) return String(sid);
- }
- return null;
- }
- // 学号获取:缓存 -> 登录服务接口 -> 手动输入兜底
- async function getStudentId() {
- const cached = getCachedStudentId();
- if (cached) return cached;
- try {
- const remote = await fetchRemoteStudentId();
- if (remote) return remote;
- } catch (e) {
- console.warn("获取学号接口失败:", e);
- }
- return await promptStudentId();
- }
- // 学号输入校验(供 showPrompt 按名回调使用;桥接在页面全局作用域查找函数,必须挂到 window 上)
- window.validateStudentId = function (studentId) {
- if (studentId === null || studentId.trim().length === 0) {
- return "学号不能为空!";
- }
- return false;
- };
- // 兜底:通过弹窗让用户手动输入学号
- async function promptStudentId() {
- const input = await window.shiguangBridgePromise.showPrompt(
- "输入学号",
- "未能自动识别学号,请输入你的学号:",
- "",
- "validateStudentId"
- );
- if (input === null) throw new Error("用户取消了学号输入");
- return input.trim();
- }
- // 通用请求封装,携带登录令牌;响应体兼容 JSON/空体/非 JSON(如网关异常页)
- async function apiFetch(url, options = {}, description = "数据") {
- const response = await fetch(url, {
- ...options,
- credentials: 'include',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${getAccessToken()}`,
- ...(options.headers || {}),
- },
- });
- if (response.status === 401 || response.status === 403) {
- window.shiguangBridge.showToast(`${description}失败,登录已失效,请退出重试`);
- throw new Error(`${description}失败: 登录状态失效 (${response.status})`);
- }
- if (!response.ok) {
- window.shiguangBridge.showToast(`${description}失败,请稍后重试`);
- throw new Error(`${description}失败: ${response.status} ${response.statusText}`);
- }
- const text = await response.text();
- if (!text || !text.trim()) return null;
- try {
- return JSON.parse(text);
- } catch (e) {
- console.warn(`${description}:响应不是 JSON(前 120 字符):`, text.slice(0, 120));
- return { raw: text };
- }
- }
- // 兼容两种响应结构:{code, data: {...}} 或扁平结构
- const unwrap = (json) => (json && typeof json.data !== 'undefined' ? json.data : json);
- // 获取学期列表(含系统当前学期 ID)
- // 优先取"已发布课表"的学期(前端课表页的选择器即用此接口),避免列出全校全量学期;
- // 该接口失败时回退到 info-detail 的 sessionFinder 全量列表。
- // session/list 仅用于补充开始日期(排序用),失败不影响流程。
- async function getSessionInfo() {
- const json = await apiFetch(`${RESOURCE_API}/session/info-detail`, {}, "获取学期信息");
- const data = unwrap(json) || {};
- let sessions = [];
- try {
- const releaseJson = await apiFetch(`${TIMETABLE_API}/optionFinder/session-release-schedule`, {}, "获取已发布课表的学期");
- const release = unwrap(releaseJson) ?? [];
- if (Array.isArray(release) && release.length > 0) {
- sessions = release
- .filter((session) => session && session.id != null)
- .map((session) => ({ id: String(session.id), name: session.name ?? String(session.id), beginDate: session.beginDate ?? null }));
- }
- } catch (e) {
- console.warn("获取已发布课表学期失败,回退全量学期列表:", e);
- }
- if (sessions.length === 0) {
- sessions = (json.sessionFinder ?? data.sessionFinder ?? [])
- .filter((session) => session && session.id != null && session.id !== '')
- .map((session) => ({ id: String(session.id), name: session.name ?? String(session.id), beginDate: null }));
- }
- try {
- const listJson = await apiFetch(`${RESOURCE_API}/session/list`, {}, "获取学期列表");
- const list = unwrap(listJson)?.sessionVOList ?? [];
- if (list.length > 0) {
- const byId = new Map(list.map((session) => [String(session.id), session]));
- sessions = sessions.map((session) => ({ ...session, beginDate: session.beginDate ?? byId.get(session.id)?.beginDate ?? null }));
- }
- } catch (e) {
- console.warn("获取学期列表失败,将按学期名称排序:", e);
- }
- const curSessionId = json.curSessionId ?? data.curSessionId ?? data.id ?? json.id;
- return { curSessionId: curSessionId == null ? null : String(curSessionId), sessions };
- }
- // 学期排序键(数值越大越新):优先学期开始日期(兼容带时间的日期串),
- // 否则解析名称中的学年与学期序号(如 "2025-2026学年第二学期"、"2026秋")
- const TERM_NUM = { "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "春": 1, "夏": 2, "秋": 3, "冬": 4 };
- function sessionSortKey(session) {
- const dateMatch = session.beginDate ? String(session.beginDate).match(/^(\d{4})-(\d{2})-(\d{2})/) : null;
- if (dateMatch) return Number(dateMatch[1] + dateMatch[2] + dateMatch[3]);
- const name = session.name || "";
- const year = Number((name.match(/\d{4}/) ?? ["0"])[0]);
- const termMatch = name.match(/第([一二三四五六七八\d]+)学期|([春夏秋冬])/);
- let term = 0;
- if (termMatch) {
- term = termMatch[1] !== undefined
- ? (TERM_NUM[termMatch[1]] ?? parseInt(termMatch[1], 10) ?? 0)
- : (TERM_NUM[termMatch[2]] ?? 0);
- }
- return year * 100 + term;
- }
- // 弹窗选择学期,返回选中学期;列表已按新到旧排序,默认选中第一项(最新学期)
- async function selectSession(sessions, curSessionId) {
- const labels = sessions.map((session) =>
- session.id === curSessionId ? `${session.name}(当前学期)` : session.name
- );
- const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
- "选择要导入的学期",
- JSON.stringify(labels),
- 0
- );
- if (selectedIndex === null || selectedIndex < 0 || selectedIndex >= sessions.length) {
- return null; // 用户取消
- }
- return sessions[selectedIndex];
- }
- // 切换服务端当前学期:课表接口按服务端会话学期返回数据,忽略 sessionId 参数。
- // 注意:前端调用此接口用的是普通 post(表单格式)而非 postJson(JSON),
- // body 必须是 urlencoded 的 curSessionId,发 JSON 会被后端静默忽略(返回 200 但不切换)
- async function switchSession(sessionId) {
- const json = await apiFetch(`${USER_API}/user-switch-session`, {
- method: 'POST',
- body: `curSessionId=${encodeURIComponent(sessionId)}`,
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- }, "切换学期");
- if (json && json.status === 'error') {
- window.shiguangBridge.showToast(`切换学期失败(${json.msg ?? '未知错误'}),请重试`);
- throw new Error(`切换学期失败: ${json.msg ?? '未知错误'}`);
- }
- }
- // 获取学期开始日期(规范化为 yyyy-MM-dd,App 无法解析带时间的日期串)
- async function getStartDate(sessionId) {
- const json = await apiFetch(`${RESOURCE_API}/session/detail/${sessionId}`, {}, "获取学期起止信息");
- const data = unwrap(json) || {};
- const raw = data.beginDate ?? json.beginDate ?? null;
- const matched = raw ? String(raw).match(/^(\d{4}-\d{2}-\d{2})/) : null;
- return matched ? matched[1] : null;
- }
- // 获取学期最大周数
- async function getMaxWeek(sessionId) {
- const json = await apiFetch(`${TIMETABLE_API}/course/maxWeek/${sessionId}`, {}, "获取最大周数");
- const maxWeek = Number(unwrap(json));
- return Number.isFinite(maxWeek) && maxWeek > 0 ? maxWeek : null;
- }
- // 获取大节时间配置(periodList 内字段为 smallPeriod/startTime/endTime)
- async function getTimeSlots() {
- const json = await apiFetch(`${RESOURCE_API}/timePattern/get-large-period`, {}, "获取时间段配置");
- const groups = unwrap(json) || [];
- let periods = (groups[0] && groups[0].periodList) || [];
- if (periods.length === 0 && groups.length > 0) {
- periods = groups.flatMap((group) => group.periodList || []);
- }
- return periods.map((period, index) => ({
- number: period.smallPeriod ?? index + 1,
- startTime: period.startTime ?? '',
- endTime: period.endTime ?? '',
- }));
- }
- // 获取指定学期、指定学号的课表原始数据
- // 优先 stu/schedule-detail(我的课表页使用),为空或失败时回退 student/my-table-detail,
- // 两个接口参数与返回结构一致(classTimetableVOList)
- async function fetchScheduleList(url, studentId, description) {
- const json = await apiFetch(url, { method: 'POST', body: JSON.stringify([studentId]) }, description);
- const payload = unwrap(json) || {};
- return payload.classTimetableVOList ?? json.classTimetableVOList ?? [];
- }
- async function getSchedule(sessionId, studentId) {
- const primary = `${TIMETABLE_API}/class/timetable/stu/schedule-detail?sessionId=${sessionId}`;
- const fallback = `${TIMETABLE_API}/class/timetable/student/my-table-detail?sessionId=${sessionId}`;
- try {
- const list = await fetchScheduleList(primary, studentId, "获取课程表");
- if (list.length > 0) return list;
- console.warn("stu/schedule-detail 返回为空,尝试备用接口");
- } catch (e) {
- console.warn("stu/schedule-detail 查询失败,尝试备用接口:", e);
- }
- return await fetchScheduleList(fallback, studentId, "获取课程表(备用)");
- }
- // 解析周次字符串,与前端逻辑一致:支持 "1-16"、"1,3,5"、"1-8,10-16" 等
- function parseWeeks(weekFormat) {
- const weeks = [];
- if (!weekFormat) return weeks;
- const parts = String(weekFormat).replace(/,/g, ',').split(',');
- for (const part of parts) {
- const range = part.split('-');
- const start = parseInt(range[0], 10);
- const end = parseInt(range[1], 10);
- if (range.length > 1) {
- if (isNaN(start) || isNaN(end)) continue;
- const [from, to] = start > end ? [end, start] : [start, end];
- for (let week = from; week <= to; week++) {
- if (week > 0) weeks.push(week);
- }
- } else if (range[0] !== '' && !isNaN(start) && start > 0) {
- weeks.push(start);
- }
- }
- return [...new Set(weeks)].sort((a, b) => a - b);
- }
- // 兼容旧版二进制周次串(如 "101010...")
- function parseBinaryWeeks(binary) {
- if (typeof binary !== 'string' || !/^[01]+$/.test(binary)) return [];
- return [...binary].reduce((weeks, char, index) => {
- if (char === '1') weeks.push(index + 1);
- return weeks;
- }, []);
- }
- // 解析节次字符串(如 "1-2" 或 "3")
- function parsePeriodFormat(periodFormat) {
- if (!periodFormat) return null;
- const parts = String(periodFormat).trim().split('-');
- const start = parseInt(parts[0], 10);
- const end = parts.length > 1 ? parseInt(parts[1], 10) : start;
- if (isNaN(start) || isNaN(end)) return null;
- return { start, end };
- }
- // 清理教师名:去掉工号与 "[主讲];" 等标记(如 "罗桓-03068[主讲];" -> "罗桓")
- function cleanTeacher(raw) {
- const text = String(raw ?? '').trim();
- if (!text) return '';
- const names = text.split(/[;,,;]/)
- .map((part) => part.replace(/\[[^\]]*\]/g, '').replace(/-\d+$/, '').trim())
- .filter(Boolean);
- return names.length > 0 ? names.join(',') : text;
- }
- // 将课表数据转换为拾光课程表格式
- function parseSchedule(schedule) {
- const courses = [];
- for (const item of schedule) {
- if (!item || !item.courseName) continue;
- const period = parsePeriodFormat(item.periodFormat);
- let weeks = parseWeeks(item.teachingWeekFormat);
- if (weeks.length === 0) weeks = parseBinaryWeeks(item.teachingWeek);
- if (!period || weeks.length === 0) continue;
- const day = Number(item.weekDay) || 0;
- if (day < 1 || day > 7) continue;
- const [startSection, endSection] = period.start <= period.end
- ? [period.start, period.end]
- : [period.end, period.start];
- courses.push({
- name: item.courseName,
- teacher: cleanTeacher(item.instructorName),
- position: item.position || item.roomName || '',
- day,
- startSection,
- endSection,
- weeks,
- });
- }
- return courses;
- }
- // 保存课表数据到拾光课程表
- async function saveSchedule(parsedSchedule) {
- const tasks = [];
- const config = {};
- if (parsedSchedule.startDate) config.semesterStartDate = parsedSchedule.startDate;
- if (parsedSchedule.maxWeek) config.semesterTotalWeeks = parsedSchedule.maxWeek;
- if (Object.keys(config).length > 0) {
- tasks.push(window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config)));
- }
- if (parsedSchedule.timeSlots.length > 0) {
- tasks.push(window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(parsedSchedule.timeSlots)));
- }
- tasks.push(window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedSchedule.courses)));
- const results = await Promise.allSettled(tasks);
- const failed = results.filter((result) => result.status === 'rejected');
- if (failed.length > 0) {
- console.error("保存过程出现错误:", failed.map((result) => result.reason));
- window.shiguangBridge.showToast("部分数据保存失败,请查看日志后重试");
- return false;
- }
- return true;
- }
- async function main() {
- if (window.location.hostname !== HOST) {
- window.shiguangBridge.showToast("请先登录并进入重庆工程学院新教务系统页面,再执行导入!");
- return;
- }
- if (!getAccessToken()) {
- window.shiguangBridge.showToast("尚未登录新教务系统,请先登录!");
- throw new Error("未检测到登录状态");
- }
- // 获取学期列表,按新到旧排序后弹窗选择,默认最新学期
- const { curSessionId, sessions } = await getSessionInfo();
- if (sessions.length === 0) {
- window.shiguangBridge.showToast("未获取到学期列表,请稍后重试");
- throw new Error("学期列表为空");
- }
- sessions.sort((a, b) => sessionSortKey(b) - sessionSortKey(a));
- const chosen = await selectSession(sessions, curSessionId);
- if (!chosen) return;
- const confirmed = await window.shiguangBridgePromise.showAlert(
- "教务系统课表导入",
- `将导入「${chosen.name}」学期的课表`,
- "好的,开始导入"
- );
- if (!confirmed) return;
- // 先切换服务端当前学期,否则课表接口会返回系统默认学期的数据
- await switchSession(chosen.id);
- const studentId = await getStudentId();
- const sessionId = chosen.id;
- const [startDate, maxWeek, timeSlots, schedule] = await Promise.all([
- getStartDate(sessionId),
- getMaxWeek(sessionId),
- getTimeSlots(),
- getSchedule(sessionId, studentId),
- ]);
- const courses = parseSchedule(schedule);
- if (courses.length === 0) {
- window.shiguangBridge.showToast("未解析到课程数据,请确认所选学期有课表后再试");
- throw new Error("课表数据为空");
- }
- const success = await saveSchedule({ startDate, maxWeek, timeSlots, courses });
- if (success) {
- window.shiguangBridge.showToast(`「${chosen.name}」导入成功,共 ${courses.length} 条课程记录!`);
- window.shiguangBridge.notifyTaskCompletion();
- }
- }
- // 兜底捕获所有未处理异常,避免导入失败时无任何提示
- main().catch((e) => {
- console.error("导入过程出现错误:", e);
- try {
- window.shiguangBridge.showToast(`导入失败:${e?.message ?? e}`);
- } catch (_) { /* 桥不可用时仅记录日志 */ }
- });
- })();
|