// 南方科技大学 TIS 教学管理与服务平台 课表导入适配器 // // 依赖的 TIS 接口(均为登录后同域请求,凭证由 WebView 自动携带): // POST /component/querydangqianxnxq 当前学年学期 { XN, XQ, XNXQ } // POST /cjgl/grcjcx/grcjcx 历年课程记录(用于生成可选学期列表) // POST /xszykb/queryxszykbzong 整学期课表(xn, xq) // POST /component/queryKbjg 作息时间(xn, xq, zc) // POST /component/querydangqianzc 当前教学周(纯数字文本) // // 课表条目关键字段(2026 秋实测): // KEY "xq1_jc5" -> 星期 1(周一),jc 为课表网格行号(非节次) // KSJC / JSJC 起始 / 结束节次(真正的节次) // ZC 0/1 位图,下标即周次(下标 0 未使用) // SKSJ 多行文本:课程名 / [教师] / [班级] / [周次][地点][节次] // KCWZSM / SKFS 等 实测为 null,仅作兜底 // ======================================== // 常量 // ======================================== // 教务系统未返回作息时间时的兜底节次时间(南科大常规作息) const FALLBACK_TIME_SLOTS = [ { number: 1, startTime: "08:00", endTime: "08:50" }, { number: 2, startTime: "09:00", endTime: "09:50" }, { number: 3, startTime: "10:20", endTime: "11:10" }, { number: 4, startTime: "11:20", endTime: "12:10" }, { number: 5, startTime: "14:00", endTime: "14:50" }, { number: 6, startTime: "15:00", endTime: "15:50" }, { number: 7, startTime: "16:20", endTime: "17:10" }, { number: 8, startTime: "17:20", endTime: "18:10" }, { number: 9, startTime: "19:00", endTime: "19:50" }, { number: 10, startTime: "20:00", endTime: "20:50" }, { number: 11, startTime: "21:20", endTime: "22:10" }, { number: 12, startTime: "22:20", endTime: "23:10" } ]; const CONFIG_BASE = { semesterTotalWeeks: 18, defaultClassDuration: 50, defaultBreakDuration: 10 }; // ======================================== // 网络请求 // ======================================== async function postFormRaw(path, params) { const body = new URLSearchParams(); Object.keys(params || {}).forEach(function (key) { const value = params[key]; body.append(key, value === null || value === undefined ? "" : String(value)); }); const response = await fetch(path, { method: "POST", credentials: "include", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With": "XMLHttpRequest" }, body: body.toString() }); if (!response.ok) { throw new Error(`请求 ${path} 失败:HTTP ${response.status}`); } return await response.text(); } async function postForm(path, params) { const text = await postFormRaw(path, params); return parseJsonSafely(text, path); } async function postJson(path, payload) { const response = await fetch(path, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", "X-Requested-With": "XMLHttpRequest" }, body: JSON.stringify(payload || {}) }); if (!response.ok) { throw new Error(`请求 ${path} 失败:HTTP ${response.status}`); } return parseJsonSafely(await response.text(), path); } function parseJsonSafely(text, path) { const trimmed = (text || "").trim(); if (!trimmed) { return null; } if (trimmed.charAt(0) === "<") { throw new Error("登录状态已失效,请重新登录本科教务系统后再导入。"); } // 会话失效时 TIS 会返回登录提示页(HTML 或带"登录"字样的 JSON) if (/重新登录|请登录|用户认证|登录已过期/.test(trimmed)) { throw new Error("登录状态已失效,请重新登录教务系统后再导入。"); } try { return JSON.parse(trimmed); } catch (error) { throw new Error( `教务系统返回了非 JSON 内容,请确认已登录本科教务系统(${path})。` ); } } // 取数组型数据:接口可能直接返回数组,也可能包在 content / data 里 function toArray(data) { if (Array.isArray(data)) { return data; } if (data && Array.isArray(data.content)) { return data.content; } if (data && Array.isArray(data.data)) { return data.data; } return []; } // ======================================== // 学期信息 // ======================================== async function fetchCurrentSemester() { const data = await postForm("/component/querydangqianxnxq", {}); if (!data || !data.XN || !data.XQ) { throw new Error("未能获取学年学期,请确认已登录本科教务系统。"); } return { xn: String(data.XN), xq: String(data.XQ), label: data.XNXQ || `${data.XN}学年第${data.XQ}学期` }; } // 通过成绩/选课接口拿到该学生有记录的所有学期,作为可选列表 async function fetchSemesterOptions() { const options = []; const seen = {}; try { const data = await postJson("/cjgl/grcjcx/grcjcx", { xn: null, xq: null, kcmc: null, cxbj: "-1", pylx: "1", current: 1, pageSize: 500 }); const rows = (data && data.content && data.content.list) || []; rows.forEach(function (row) { addSemesterOption(options, seen, row && row.xnxq, row && row.xnxqmc); }); } catch (error) { // 拿不到历史学期列表时不阻塞流程,调用方会退回"仅当前学期" return options; } return options; } function addSemesterOption(options, seen, xnxq, label) { const matched = String(xnxq || "").match(/^(\d{4}-\d{4})(\d)$/); if (!matched) { return; } const xn = matched[1]; const xq = matched[2]; const key = `${xn}-${xq}`; if (seen[key]) { return; } seen[key] = true; options.push({ xn: xn, xq: xq, key: key, label: label || key }); } function mergeCurrentSemester(options, current) { const key = `${current.xn}-${current.xq}`; const exists = options.some(function (item) { return item.key === key; }); if (!exists) { options.push({ xn: current.xn, xq: current.xq, key: key, label: current.label }); } options.sort(function (a, b) { return b.key < a.key ? -1 : 1; }); return options; } // ======================================== // 用户交互 // ======================================== function validateDateInput(input) { const text = String(input || "").trim(); if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) { return "请输入 YYYY-MM-DD 格式的日期!"; } const date = new Date(`${text}T00:00:00`); if (isNaN(date.getTime())) { return "请输入有效的日期!"; } return false; } async function confirmStart() { return await window.shiguangBridgePromise.showAlert( "导入说明", "本适配用于导入南方科技大学 TIS 本科教务系统的课表。\n请先完成 CAS 登录并停留在教务系统页面内,再点击确认开始导入。", "开始导入" ); } async function selectSemester(options, current) { if (!options.length) { return { xn: current.xn, xq: current.xq, key: `${current.xn}-${current.xq}` }; } const labels = options.map(function (item) { return `${item.label}(${item.xn}学年第${item.xq}学期)`; }); const defaultIndex = options.findIndex(function (item) { return item.key === `${current.xn}-${current.xq}`; }); const index = await window.shiguangBridgePromise.showSingleSelection( "选择学期", JSON.stringify(labels), defaultIndex >= 0 ? defaultIndex : 0 ); if (index === null || index < 0 || index >= options.length) { return null; } return options[index]; } async function askSemesterStartDate(defaultValue) { const input = await window.shiguangBridgePromise.showPrompt( "学期开始日期", "该学期不是当前学期,无法自动推算周次。\n请输入该学期第 1 周星期一的日期:", defaultValue, "validateDateInput" ); if (input === null) { return null; } return String(input).trim(); } // ======================================== // 课表数据与作息时间 // ======================================== async function fetchTimeSlots(xn, xq) { try { const data = await postForm("/component/queryKbjg", { xn: xn, xq: xq, zc: "1" }); const slots = toArray(data).map(function (item) { const number = toNumber(item.xj); const startTime = normalizeTime(item.kssj); const endTime = normalizeTime(item.jssj); if (!number || !startTime || !endTime) { return null; } return { number: number, startTime: startTime, endTime: endTime }; }).filter(Boolean); if (slots.length) { slots.sort(function (a, b) { return a.number - b.number; }); return slots; } } catch (error) { // 忽略,使用兜底作息 } return FALLBACK_TIME_SLOTS.slice(); } async function fetchCourses(xn, xq) { const data = await postForm("/xszykb/queryxszykbzong", { xn: xn, xq: xq }); const rows = toArray(data); if (!rows.length) { throw new Error("教务系统未返回课表数据,请确认该学期已选课。"); } return parseCourses(rows); } function parseCourses(rows) { const courses = []; const seen = {}; rows.forEach(function (row) { const course = parseCourse(row); if (!course) { return; } const identity = [ course.name, course.day, course.startSection, course.endSection, course.weeks.join(","), course.teacher, course.position ].join("|"); // 同一节课会在课表网格中占多行(KEY 的 jc 不同),需要去重 if (seen[identity]) { return; } seen[identity] = true; courses.push(course); }); return courses; } function parseCourse(row) { if (!row || typeof row !== "object") { return null; } const keyMatch = String(row.KEY || "").match(/xq(\d+)_jc(\d+)/i); const day = keyMatch ? toNumber(keyMatch[1]) : null; if (!day || day < 1 || day > 7) { return null; } const detail = parseScheduleText(row.SKSJ || row.SKSJ_EN); // 真实数据中 KCWZSM 为 null,课程信息以 SKSJ 为准 const name = firstText(detail.name, row.KCMC, row.KCWZSM); if (!name) { return null; } // jc 只是网格行号,真实节次用 KSJC / JSJC const slotIndex = keyMatch ? toNumber(keyMatch[2]) : null; const startSection = toNumber(row.KSJC) || slotIndex; const endSection = toNumber(row.JSJC) || startSection; const weeks = parseWeekBitmap(row.ZC, detail.declaredWeeks); if (!startSection || !endSection || endSection < startSection || !weeks.length) { return null; } return { name: name, teacher: firstText(detail.teacher, row.SKJS, row.JSXM, row.DGJSMC), position: firstText(detail.position, row.SKDD, row.JXCDMC, row.SKDDMC), day: day, startSection: startSection, endSection: endSection, weeks: weeks }; } // ZC 是 0/1 位图,实测下标即周次(下标 0 未使用)。 // 为兼容不同学期的差异,同时算出两种解释,用 SKSJ 里声明的周次(如 [1-16周])校准。 function parseWeekBitmap(value, declaredWeeks) { const bitmap = String(value || ""); if (!/^[01]+$/.test(bitmap)) { return declaredWeeks || []; } function build(shift) { const weeks = []; for (let index = 0; index < bitmap.length; index += 1) { if (bitmap[index] === "1") { const week = index + 1 - shift; if (week > 0) { weeks.push(week); } } } return weeks; } // shift=1:周次 = 下标(教务实际使用的口径);shift=0:周次 = 下标 + 1 const candidates = [build(1), build(0)]; if (!declaredWeeks || !declaredWeeks.length) { return candidates[0]; } let best = candidates[0]; let bestScore = -1; candidates.forEach(function (weeks) { const set = {}; weeks.forEach(function (week) { set[week] = true; }); const score = declaredWeeks.filter(function (week) { return set[week]; }).length; if (score > bestScore) { bestScore = score; best = weeks; } }); return best; } const WEEK_PATTERN = /\d[\d,\-]*(单|双)?周/; // 1-16周 / 1-15单周 / 1-9,11周 const SECTION_PATTERN = /\d+\s*(-\s*\d+)?\s*节/; // 9-10节 / 3节 // 详情行:含周次或节次的那些行,如 [1-16周][一教110][1-2节]、[3-16周]、[教室][1-4节] function isDetailLine(line) { return WEEK_PATTERN.test(line) || SECTION_PATTERN.test(line); } // 解析 "1-16周" / "1-9,11,13-15周" / "1-15单周" 形式的周次声明 function parseWeekExpression(text) { const matched = String(text).match(WEEK_PATTERN); if (!matched) { return []; } // 去掉末尾的 "单周" / "双周" / "周",剩下的就是周次表达式 const expression = matched[0].replace(/(单|双)?周$/, ""); const parity = /单周$/.test(matched[0]) ? "单" : (/双周$/.test(matched[0]) ? "双" : ""); const weeks = []; expression.split(",").forEach(function (part) { const range = part.match(/^(\d+)(?:-(\d+))?$/); if (!range) { return; } const start = Number(range[1]); const end = range[2] ? Number(range[2]) : start; for (let week = start; week <= end && week <= 36; week += 1) { weeks.push(week); } }); const filtered = weeks.filter(function (week) { if (parity === "单") { return week % 2 === 1; } if (parity === "双") { return week % 2 === 0; } return true; }); return uniqueSorted(filtered); } // SKSJ 形如: // 思想道德与法治 // [兰美荣] // [思想道德与法治-09班-中文] // [1-16周][智华楼207][9-10节] function parseScheduleText(value) { const lines = String(value || "") .replace(//gi, "\n") .replace(/<[^>]+>/g, "\n") .split("\n") .map(function (line) { return line.trim(); }) .filter(Boolean); const result = { name: "", teacher: "", position: "", declaredWeeks: [] }; if (!lines.length) { return result; } result.name = unwrap(lines[0]); lines.slice(1).forEach(function (line) { const groups = bracketGroups(line); // 详情行:[1-16周][智华楼207][9-10节] // 注意:不能用 "含周/含节" 判断,课程名或教师名里可能有"周"字(如"某单周课") if (isDetailLine(line)) { if (!result.declaredWeeks.length) { result.declaredWeeks = parseWeekExpression(line); } if (!result.position) { const place = firstMatch(groups, function (part) { return part && !WEEK_PATTERN.test(part) && !SECTION_PATTERN.test(part) && !/班/.test(part) && part !== result.name && part.indexOf(result.name) < 0 && result.name.indexOf(part) < 0; }); if (place) { result.position = unwrap(place); } } return; } const text = unwrap(line); // 教师行:课程名的下一行,排除班级行(含"班")与课程名本身 if (!result.teacher && text && text !== result.name && !/班/.test(text)) { result.teacher = text; } }); return result; } // ======================================== // 课表配置(学期开始日期 / 总周数) // ======================================== async function fetchCurrentWeek() { try { const text = (await postFormRaw("/component/querydangqianzc", {})).trim(); if (/^\d+$/.test(text)) { return Number(text); } } catch (error) { // 学期未开始时该接口返回空内容 } return null; } // 本周周一往前推 (当前周 - 1) 周,即为第 1 周周一 function computeSemesterStart(currentWeek) { if (!currentWeek || currentWeek < 1) { return ""; } const now = new Date(); const monday = new Date( now.getFullYear(), now.getMonth(), now.getDate() - ((now.getDay() + 6) % 7) ); monday.setDate(monday.getDate() - (currentWeek - 1) * 7); return formatDate(monday); } async function buildCourseConfig(semester, current, courses) { const maxWeek = courses.reduce(function (max, course) { return Math.max(max, course.weeks[course.weeks.length - 1] || 0); }, 0); const config = Object.assign({}, CONFIG_BASE, { semesterTotalWeeks: Math.max(CONFIG_BASE.semesterTotalWeeks, maxWeek) }); const isCurrent = semester && current && semester.key === `${current.xn}-${current.xq}`; let startDate = isCurrent ? computeSemesterStart(await fetchCurrentWeek()) : ""; if (!startDate) { startDate = await askSemesterStartDate(computeSemesterStart(1)); if (!startDate) { return null; } } config.semesterStartDate = startDate; return config; } // ======================================== // 工具函数 // ======================================== function firstText() { for (let index = 0; index < arguments.length; index += 1) { const value = arguments[index]; if (value !== null && value !== undefined && String(value).trim() !== "") { return String(value).trim(); } } return ""; } // 去掉一对包裹的中括号:[兰美荣] -> 兰美荣 function unwrap(value) { return String(value || "").trim() .replace(/^\[/, "") .replace(/\]$/, "") .trim(); } // 提取一行里所有 [xxx] 的内容 function bracketGroups(line) { const groups = []; const pattern = /\[([^\]]*)\]/g; let matched = pattern.exec(String(line)); while (matched !== null) { groups.push(matched[1].trim()); matched = pattern.exec(String(line)); } return groups; } function firstMatch(list, predicate) { for (let index = 0; index < list.length; index += 1) { if (predicate(list[index])) { return list[index]; } } return null; } function uniqueSorted(numbers) { const seen = {}; const result = []; numbers.forEach(function (number) { if (!seen[number]) { seen[number] = true; result.push(number); } }); return result.sort(function (a, b) { return a - b; }); } function toNumber(value) { if (value === null || value === undefined || value === "") { return null; } const number = Number(value); return Number.isFinite(number) && number > 0 ? number : null; } function normalizeTime(value) { const text = String(value || "").trim(); return /^\d{1,2}:\d{2}$/.test(text) ? text : ""; } function formatDate(date) { const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${date.getFullYear()}-${month}-${day}`; } function toast(message) { if (window.shiguangBridge && typeof window.shiguangBridge.showToast === "function") { window.shiguangBridge.showToast(message); } } // ======================================== // 主流程 // ======================================== async function runImportFlow() { try { if (!window.shiguangBridgePromise) { throw new Error("当前环境不支持导入,请在时光课程表 App 内运行。"); } const confirmed = await confirmStart(); if (!confirmed) { toast("已取消导入。"); return; } toast("正在获取学期信息..."); const current = await fetchCurrentSemester(); const options = mergeCurrentSemester(await fetchSemesterOptions(), current); const semester = await selectSemester(options, current); if (!semester) { toast("已取消导入。"); return; } toast("正在获取课表..."); const courses = await fetchCourses(semester.xn, semester.xq); if (!courses.length) { throw new Error("未解析到任何课程,请确认该学期课表已发布。"); } const timeSlots = await fetchTimeSlots(semester.xn, semester.xq); const config = await buildCourseConfig(semester, current, courses); if (!config) { toast("已取消导入。"); return; } toast(`正在导入 ${courses.length} 门课程...`); await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses)); await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots)); await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config)); toast(`成功导入 ${courses.length} 门课程!`); if (window.shiguangBridge && typeof window.shiguangBridge.notifyTaskCompletion === "function") { window.shiguangBridge.notifyTaskCompletion(); } } catch (error) { toast(`导入失败:${error.message}`); } } runImportFlow();