feat: 模板适配层、易班能力与全局状态

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.8 committed 2026-08-18 13:39:42 +08:00
1 parent 3995656175
commit d3ae0a8ca7
45 files changed
+6931 -179

No files matched your search

+3
View File
@@ -1,3 +1,6 @@
export * from "./assets";
export * from "./lazyRoute";
export * from "./tailwind";
export * from "./useOrientation";
export * from "./useScale";
export * from "./yiban";
+1 -10
View File
@@ -1,10 +1 @@
import { twMerge } from "tailwind-merge";
/**
* 合并 Tailwind CSS 类名,自动处理冲突
* @param classes - 要合并的类名
* @returns 合并后的类名字符串
*/
export function cn(...classes: (string | undefined | null | false)[]): string {
return twMerge(classes.filter(Boolean).join(" "));
}
export { cn } from "@/lib/cn";
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useState } from "react";
function getIsPortrait() {
if (typeof window === "undefined") return false;
return window.innerHeight > window.innerWidth;
}
export function useOrientation() {
const [isPortrait, setIsPortrait] = useState(getIsPortrait);
useEffect(() => {
const updateOrientation = () => setIsPortrait(getIsPortrait());
window.addEventListener("resize", updateOrientation);
window.addEventListener("orientationchange", updateOrientation);
return () => {
window.removeEventListener("resize", updateOrientation);
window.removeEventListener("orientationchange", updateOrientation);
};
}, []);
return isPortrait;
}
+43
View File
@@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from "react";
import { appConfig } from "@/app.config";
export const DESIGN_WIDTH = appConfig.design.width;
export const DESIGN_HEIGHT = appConfig.design.height;
function getViewportSize() {
if (typeof window === "undefined") {
return { width: DESIGN_WIDTH, height: DESIGN_HEIGHT };
}
return {
width: window.visualViewport?.width ?? window.innerWidth,
height: window.visualViewport?.height ?? window.innerHeight,
};
}
export function useScale() {
const calculateScale = useCallback(() => {
const viewport = getViewportSize();
return Math.min(
viewport.width / DESIGN_WIDTH,
viewport.height / DESIGN_HEIGHT,
appConfig.scale.max,
);
}, []);
const [scale, setScale] = useState(calculateScale);
useEffect(() => {
const updateScale = () => setScale(calculateScale());
window.addEventListener("resize", updateScale);
window.addEventListener("orientationchange", updateScale);
window.visualViewport?.addEventListener("resize", updateScale);
window.visualViewport?.addEventListener("scroll", updateScale);
return () => {
window.removeEventListener("resize", updateScale);
window.removeEventListener("orientationchange", updateScale);
window.visualViewport?.removeEventListener("resize", updateScale);
window.visualViewport?.removeEventListener("scroll", updateScale);
};
}, [calculateScale]);
return scale;
}
+21
View File
@@ -0,0 +1,21 @@
/**
* vconsole 移动端调试工具
* 仅在开发环境的移动端启用
*/
function isMobileDevice() {
const userAgent = navigator.userAgent.toLowerCase();
const isMobileUA = /android|iphone|ipad|ipod|mobile/.test(userAgent);
const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
return isMobileUA || hasTouch;
}
if (import.meta.env.DEV && isMobileDevice()) {
void import("vconsole")
.then(({ default: VConsole }) => {
new VConsole();
})
.catch((error) => {
console.warn("[vconsole] failed to load", error);
});
}
+485
View File
@@ -0,0 +1,485 @@
/**
* 易班 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(),
});
}
/**
* 获取 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 === undefined ||
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;
}
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;
}