/** * 易班 videoajax.js 封装 * 用于与易班平台交互:用户数据存取、课程进度上报等 */ import { useUserStore } from "@/stores"; import type { BreakPointParams, StorageBackend, StorageOptions, UserDefineParam, UserStorageFetchResult, VideoAjax, VideoAjaxResponse, YibanRequestResult, } from "@/types/yiban"; // 超时时间 const TIMEOUT_MS = 1000 * 6; // 课时前缀 const LESSON_PREFIX = "hsgrl"; // 初始化 videoajax 实例 let videoajaxInstance: VideoAjax | null = null; /** * 检测 videoajax.js 是否已加载 */ export function isVideoAjaxAvailable() { return typeof globalThis.videoAjax === "function"; } /** * 记录 videoajax 不可用的诊断信息 */ function warnVideoAjaxUnavailable(description: string) { console.warn(`[yiban] videoajax unavailable: ${description}`, { hasVideoAjax: isVideoAjaxAvailable(), }); } /** * 从 URL 参数初始化易班会话状态 * 在 React 渲染前调用,确保页面首次读取进度时 courseId / userId 已写入 store */ export function initializeYibanSession() { const params = new URLSearchParams(window.location.search); const courseId = params.get("courseId") ?? params.get("courseid"); const uid = params.get("uid"); const forceYiban = params.get("yiban") === "1"; const hasYibanParams = !!(courseId || uid); const hasVideoAjax = isVideoAjaxAvailable(); const isYibanEnv = import.meta.env.DEV ? hasYibanParams && forceYiban : hasYibanParams; const storageMode = isYibanEnv && hasVideoAjax ? "yiban" : "session"; const userStore = useUserStore.getState(); userStore.setYibanConnectionStatus( storageMode === "yiban" ? "connected" : "disconnected", ); if (courseId) userStore.setCourseId(courseId); if (uid) userStore.setUserInfo({ userId: uid }); console.info( storageMode === "yiban" ? "[yiban] initialized" : "[yiban] use session storage fallback", { courseId, uid, isDev: import.meta.env.DEV, forceYiban, hasYibanParams, hasVideoAjax, storageMode, }, ); } /** * 获取 VideoAjax 实例 */ function getVideoAjax(): VideoAjax { if (!videoajaxInstance) { const createVideoAjax = globalThis.videoAjax; if (typeof createVideoAjax !== "function") { throw new Error( "videoAjax is not available. Please ensure public/videoajax/videoajax.js is loaded.", ); } videoajaxInstance = createVideoAjax(); videoajaxInstance.setHost("/"); } return videoajaxInstance; } /** * 生成存储键 */ const getStorageKey = (lessonId: string | number, key: string | number) => { const { courseId, userId } = useUserStore.getState(); const courseScope = courseId || "dev-course"; const userScope = userId || "guest"; return `${LESSON_PREFIX}_${courseScope}_${userScope}_${lessonId}_${key}`; }; /** * 解析存储后端 */ const resolveStorageBackend = ( backend: StorageBackend = "auto", ): "yiban" | "session" => { if (backend === "session") return backend; if (backend === "yiban") { if (!isVideoAjaxAvailable()) { warnVideoAjaxUnavailable("force yiban storage fallback to session"); return "session"; } return backend; } const { yibanConnectionStatus } = useUserStore.getState(); if (yibanConnectionStatus === "connected" && isVideoAjaxAvailable()) { return "yiban"; } if (yibanConnectionStatus === "connected") { warnVideoAjaxUnavailable("auto storage fallback to session"); } return "session"; }; /** * 统一的易班请求封装 * 处理超时、错误、日志 */ async function yibanRequest(options: { method: (callback: (res: T) => void) => void; timeout?: number; description?: string; }): Promise> { const { method, timeout = TIMEOUT_MS, description = "request" } = options; return new Promise((resolve) => { let settled = false; const timeoutId = setTimeout(() => { if (!settled) { settled = true; console.warn(`[yiban] ${description} timeout`); resolve({ success: false, error: "timeout" }); } }, timeout); try { method((res) => { if (!settled) { settled = true; clearTimeout(timeoutId); resolve({ success: true, data: res }); } }); } catch (error) { if (!settled) { settled = true; clearTimeout(timeoutId); console.error(`[yiban] ${description} failed`, error); resolve({ success: false, error: String(error) }); } } }); } async function yibanStatusRequest(options: { method: (callback: (status: boolean, data: T) => void) => void; timeout?: number; description?: string; }): Promise> { const { method, timeout = TIMEOUT_MS, description = "request" } = options; return new Promise((resolve) => { let settled = false; const timeoutId = setTimeout(() => { if (!settled) { settled = true; console.warn(`[yiban] ${description} timeout`); resolve({ success: false, error: "timeout" }); } }, timeout); try { method((status, data) => { if (!settled) { settled = true; clearTimeout(timeoutId); resolve( status ? { success: true, data } : { success: false, error: `${description} returned false` }, ); } }); } catch (error) { if (!settled) { settled = true; clearTimeout(timeoutId); console.error(`[yiban] ${description} failed`, error); resolve({ success: false, error: String(error) }); } } }); } /** * 设置用户存储数据 * @param lessonId 课时唯一ID * @param key 数据键 * @param value 数据值 * @param options 存储选项 */ export async function setUserStorage( lessonId: string | number, key: string | number, value: string, options: StorageOptions = {}, ): Promise { const backend = resolveStorageBackend(options.backend); if (backend === "session") { sessionStorage.setItem(getStorageKey(lessonId, key), value); return { success: true }; } const param: UserDefineParam = { lessonId: `${LESSON_PREFIX}_${lessonId}`, key, content: value, }; const result = await yibanRequest({ method: (callback) => getVideoAjax().addUserDefine(param, callback), description: `setUserStorage(${lessonId}, ${key})`, }); return result; } /** * 获取用户存储数据 * @param lessonId 课时唯一ID * @param key 数据键 * @param options 存储选项 * @returns Promise */ export async function getUserStorage( lessonId: string | number, key: string | number, options: StorageOptions = {}, ): Promise { const backend = resolveStorageBackend(options.backend); if (backend === "session") { const itemValue = sessionStorage.getItem(getStorageKey(lessonId, key)); return itemValue === null || itemValue === "null" || itemValue === "" ? { status: "empty", content: "" } : { status: "success", content: itemValue }; } const param: UserDefineParam = { lessonId: `${LESSON_PREFIX}_${lessonId}`, order: key, }; const result = await yibanRequest({ method: (callback) => getVideoAjax().getUserDefineInfoByUser(param, callback), description: `getUserStorage(${lessonId}, ${key})`, }); if (!result.success) { return { status: "error", content: "", message: result.error || "请求失败", }; } const res = result.data; if (!res || res === null || res === ("null" as unknown)) { return { status: "empty", content: "" }; } if (res.code === 202) { return { status: "unauthenticated", content: "", code: res.code, message: res.msg, }; } if (res.code !== 200) { return { status: "error", content: "", code: res.code, message: res.msg || "学习进度获取失败", }; } const value = res.data?.value; if ( value === undefined || value === null || value === "null" || value === "" ) { return { status: "empty", content: "" }; } const json = Array.isArray(value) ? value : []; const content = json[0]?.content || ""; return content === "" || content === "null" ? { status: "empty", content: "" } : { status: "success", content }; } /** * 上报课程进度到易班平台 * @param order 课时顺序号 * @returns Promise 是否成功上报 */ export async function reportCourseProgress(order: number): Promise { const { yibanConnectionStatus, courseId } = useUserStore.getState(); if (yibanConnectionStatus !== "connected") { console.info(`[yiban] skip finishCourse: disconnected, order=${order}`); return false; } if (courseId === undefined || courseId === null || courseId === "") { console.info(`[yiban] skip finishCourse: missing courseId, order=${order}`); return false; } if (!isVideoAjaxAvailable()) { warnVideoAjaxUnavailable(`skip finishCourse, order=${order}`); return false; } if (order <= 0) { console.info(`[yiban] skip finishCourse: invalid order=${order}`); return false; } const result = await yibanStatusRequest({ method: (callback) => getVideoAjax().finishCourse( { order, co: courseId, process: 100 }, callback, ), description: `reportCourseProgress(${order})`, }); if (result.success) { console.info(`[yiban] finishCourse success, order=${order}`); } return result.success; } /** * 获取课程进度 * @param order 课时顺序号 * @returns Promise<课程进度数据 | null> */ export async function getCourseProgress( order: number, ): Promise { const { yibanConnectionStatus, courseId } = useUserStore.getState(); if (yibanConnectionStatus !== "connected" || !courseId) { return null; } if (!isVideoAjaxAvailable()) { warnVideoAjaxUnavailable(`skip getfinishCourse, order=${order}`); return null; } const result = await yibanRequest({ method: (callback) => getVideoAjax().getfinishCourse({ order, co: courseId }, callback), description: `getCourseProgress(${order})`, }); return result.success && result.data ? result.data : null; } /** * 保存播放断点 * @param params 断点参数 * @returns Promise 是否成功保存 */ export async function saveBreakPoint( params: BreakPointParams & { breakPoint: number }, ): Promise { const { yibanConnectionStatus } = useUserStore.getState(); if (yibanConnectionStatus !== "connected") { return false; } if (!isVideoAjaxAvailable()) { warnVideoAjaxUnavailable( `skip quitBreakPoint, lessonId=${params.lessonId}`, ); return false; } const result = await yibanStatusRequest({ method: (callback) => getVideoAjax().quitBreakPoint( { user_id: params.userId, course_id: params.courseId, lesson_id: params.lessonId, break_point: params.breakPoint, }, callback, ), description: `saveBreakPoint(${params.lessonId}, ${params.breakPoint})`, }); return result.success; } /** * 获取播放断点 * @param params 断点参数 * @returns Promise<断点数据 | null> */ export async function getBreakPoint( params: BreakPointParams, ): Promise { const { yibanConnectionStatus } = useUserStore.getState(); if (yibanConnectionStatus !== "connected") { return null; } if (!isVideoAjaxAvailable()) { warnVideoAjaxUnavailable(`skip getBreakPoint, lessonId=${params.lessonId}`); return null; } const result = await yibanStatusRequest({ method: (callback) => getVideoAjax().getBreakPoint( { user_id: params.userId, course_id: params.courseId, lesson_id: params.lessonId, }, callback, ), description: `getBreakPoint(${params.lessonId})`, }); return result.success ? result.data : null; } /** * 保存用户答题记录 * @param params 答题参数 * @returns Promise 是否成功保存 */ export async function saveUserAnswer(params: { userId: string; lessonId: string; courseId: string; questionOrder: number; answer: string; }): Promise { const { yibanConnectionStatus } = useUserStore.getState(); if (yibanConnectionStatus !== "connected") { return false; } if (!isVideoAjaxAvailable()) { warnVideoAjaxUnavailable( `skip saveUserAnswer, lessonId=${params.lessonId}`, ); return false; } // 易班 API 以 getUserAnswer 写入答题记录,方法名与「保存」语义相反,属平台既有命名 const result = await yibanStatusRequest({ method: (callback) => getVideoAjax().getUserAnswer( { user_id: params.userId, lesson_id: params.lessonId, course_id: params.courseId, question_order: params.questionOrder, answer: params.answer, }, callback, ), description: `saveUserAnswer(${params.lessonId}, Q${params.questionOrder})`, }); return result.success; }