forked from zhongyidian/yx-react-template
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
523 lines
12 KiB
TypeScript
523 lines
12 KiB
TypeScript
/**
|
|
* 易班 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<T>(options: {
|
|
method: (callback: (res: T) => void) => void;
|
|
timeout?: number;
|
|
description?: string;
|
|
}): Promise<YibanRequestResult<T>> {
|
|
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<T>(options: {
|
|
method: (callback: (status: boolean, data: T) => void) => void;
|
|
timeout?: number;
|
|
description?: string;
|
|
}): Promise<YibanRequestResult<T>> {
|
|
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<YibanRequestResult> {
|
|
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<UserStorageFetchResult>
|
|
*/
|
|
export async function getUserStorage(
|
|
lessonId: string | number,
|
|
key: string | number,
|
|
options: StorageOptions = {},
|
|
): Promise<UserStorageFetchResult> {
|
|
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<VideoAjaxResponse | undefined>({
|
|
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<boolean> 是否成功上报
|
|
*/
|
|
export async function reportCourseProgress(order: number): Promise<boolean> {
|
|
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<unknown>({
|
|
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<unknown | null> {
|
|
const { yibanConnectionStatus, courseId } = useUserStore.getState();
|
|
|
|
if (yibanConnectionStatus !== "connected" || !courseId) {
|
|
return null;
|
|
}
|
|
|
|
if (!isVideoAjaxAvailable()) {
|
|
warnVideoAjaxUnavailable(`skip getfinishCourse, order=${order}`);
|
|
return null;
|
|
}
|
|
|
|
const result = await yibanRequest<unknown>({
|
|
method: (callback) =>
|
|
getVideoAjax().getfinishCourse({ order, co: courseId }, callback),
|
|
description: `getCourseProgress(${order})`,
|
|
});
|
|
|
|
return result.success && result.data ? result.data : null;
|
|
}
|
|
|
|
/**
|
|
* 保存播放断点
|
|
* @param params 断点参数
|
|
* @returns Promise<boolean> 是否成功保存
|
|
*/
|
|
export async function saveBreakPoint(
|
|
params: BreakPointParams & { breakPoint: number },
|
|
): Promise<boolean> {
|
|
const { yibanConnectionStatus } = useUserStore.getState();
|
|
|
|
if (yibanConnectionStatus !== "connected") {
|
|
return false;
|
|
}
|
|
|
|
if (!isVideoAjaxAvailable()) {
|
|
warnVideoAjaxUnavailable(
|
|
`skip quitBreakPoint, lessonId=${params.lessonId}`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const result = await yibanStatusRequest<unknown>({
|
|
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<unknown | null> {
|
|
const { yibanConnectionStatus } = useUserStore.getState();
|
|
|
|
if (yibanConnectionStatus !== "connected") {
|
|
return null;
|
|
}
|
|
|
|
if (!isVideoAjaxAvailable()) {
|
|
warnVideoAjaxUnavailable(`skip getBreakPoint, lessonId=${params.lessonId}`);
|
|
return null;
|
|
}
|
|
|
|
const result = await yibanStatusRequest<unknown>({
|
|
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<boolean> 是否成功保存
|
|
*/
|
|
export async function saveUserAnswer(params: {
|
|
userId: string;
|
|
lessonId: string;
|
|
courseId: string;
|
|
questionOrder: number;
|
|
answer: string;
|
|
}): Promise<boolean> {
|
|
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<unknown>({
|
|
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;
|
|
}
|