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

File diff suppressed because it is too large. Load diff
+457
View File
@@ -0,0 +1,457 @@
/*
* $Id: base64.js,v 2.15 2014/04/05 12:58:57 dankogai Exp dankogai $
*
* Licensed under the MIT license.
* http://opensource.org/licenses/mit-license
*
* References:
* http://en.wikipedia.org/wiki/Base64
*/
;(function (global) {
'use strict'
// existing version for noConflict()
var _Base64 = global.Base64
var version = '2.1.5'
// if node.js, we use Buffer
var buffer
if (typeof module !== 'undefined' && module.exports) {
buffer = require('buffer').Buffer
}
// constants
var b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
var b64tab = (function (bin) {
var t = {}
for (var i = 0, l = bin.length; i < l; i++) t[bin.charAt(i)] = i
return t
})(b64chars)
var fromCharCode = String.fromCharCode
// encoder stuff
var cb_utob = function (c) {
if (c.length < 2) {
var cc = c.charCodeAt(0)
return cc < 0x80
? c
: cc < 0x800
? fromCharCode(0xc0 | (cc >>> 6)) + fromCharCode(0x80 | (cc & 0x3f))
: fromCharCode(0xe0 | ((cc >>> 12) & 0x0f)) +
fromCharCode(0x80 | ((cc >>> 6) & 0x3f)) +
fromCharCode(0x80 | (cc & 0x3f))
} else {
var cc = 0x10000 + (c.charCodeAt(0) - 0xd800) * 0x400 + (c.charCodeAt(1) - 0xdc00)
return (
fromCharCode(0xf0 | ((cc >>> 18) & 0x07)) +
fromCharCode(0x80 | ((cc >>> 12) & 0x3f)) +
fromCharCode(0x80 | ((cc >>> 6) & 0x3f)) +
fromCharCode(0x80 | (cc & 0x3f))
)
}
}
var re_utob = /[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g
var utob = function (u) {
return u.replace(re_utob, cb_utob)
}
var cb_encode = function (ccc) {
var padlen = [0, 2, 1][ccc.length % 3],
ord =
(ccc.charCodeAt(0) << 16) |
((ccc.length > 1 ? ccc.charCodeAt(1) : 0) << 8) |
(ccc.length > 2 ? ccc.charCodeAt(2) : 0),
chars = [
b64chars.charAt(ord >>> 18),
b64chars.charAt((ord >>> 12) & 63),
padlen >= 2 ? '=' : b64chars.charAt((ord >>> 6) & 63),
padlen >= 1 ? '=' : b64chars.charAt(ord & 63),
]
return chars.join('')
}
var btoa = global.btoa
? function (b) {
return global.btoa(b)
}
: function (b) {
return b.replace(/[\s\S]{1,3}/g, cb_encode)
}
var _encode = buffer
? function (u) {
return new buffer(u).toString('base64')
}
: function (u) {
return btoa(utob(u))
}
var encode = function (u, urisafe) {
return !urisafe
? _encode(u)
: _encode(u)
.replace(/[+\/]/g, function (m0) {
return m0 == '+' ? '-' : '_'
})
.replace(/=/g, '')
}
var encodeURI = function (u) {
return encode(u, true)
}
// decoder stuff
var re_btou = new RegExp(
['[\xC0-\xDF][\x80-\xBF]', '[\xE0-\xEF][\x80-\xBF]{2}', '[\xF0-\xF7][\x80-\xBF]{3}'].join('|'),
'g'
)
var cb_btou = function (cccc) {
switch (cccc.length) {
case 4:
var cp =
((0x07 & cccc.charCodeAt(0)) << 18) |
((0x3f & cccc.charCodeAt(1)) << 12) |
((0x3f & cccc.charCodeAt(2)) << 6) |
(0x3f & cccc.charCodeAt(3)),
offset = cp - 0x10000
return fromCharCode((offset >>> 10) + 0xd800) + fromCharCode((offset & 0x3ff) + 0xdc00)
case 3:
return fromCharCode(
((0x0f & cccc.charCodeAt(0)) << 12) |
((0x3f & cccc.charCodeAt(1)) << 6) |
(0x3f & cccc.charCodeAt(2))
)
default:
return fromCharCode(((0x1f & cccc.charCodeAt(0)) << 6) | (0x3f & cccc.charCodeAt(1)))
}
}
var btou = function (b) {
return b.replace(re_btou, cb_btou)
}
var cb_decode = function (cccc) {
var len = cccc.length,
padlen = len % 4,
n =
(len > 0 ? b64tab[cccc.charAt(0)] << 18 : 0) |
(len > 1 ? b64tab[cccc.charAt(1)] << 12 : 0) |
(len > 2 ? b64tab[cccc.charAt(2)] << 6 : 0) |
(len > 3 ? b64tab[cccc.charAt(3)] : 0),
chars = [fromCharCode(n >>> 16), fromCharCode((n >>> 8) & 0xff), fromCharCode(n & 0xff)]
chars.length -= [0, 0, 2, 1][padlen]
return chars.join('')
}
var atob = global.atob
? function (a) {
return global.atob(a)
}
: function (a) {
return a.replace(/[\s\S]{1,4}/g, cb_decode)
}
var _decode = buffer
? function (a) {
return new buffer(a, 'base64').toString()
}
: function (a) {
return btou(atob(a))
}
var decode = function (a) {
return _decode(
a
.replace(/[-_]/g, function (m0) {
return m0 == '-' ? '+' : '/'
})
.replace(/[^A-Za-z0-9\+\/]/g, '')
)
}
var noConflict = function () {
var Base64 = global.Base64
global.Base64 = _Base64
return Base64
}
// export Base64
global.Base64 = {
VERSION: version,
atob: atob,
btoa: btoa,
fromBase64: decode,
toBase64: encode,
utob: utob,
encode: encode,
encodeURI: encodeURI,
btou: btou,
decode: decode,
noConflict: noConflict,
}
// if ES5 is available, make Base64.extendString() available
if (typeof Object.defineProperty === 'function') {
var noEnum = function (v) {
return { value: v, enumerable: false, writable: true, configurable: true }
}
global.Base64.extendString = function () {
Object.defineProperty(
String.prototype,
'fromBase64',
noEnum(function () {
return decode(this)
})
)
Object.defineProperty(
String.prototype,
'toBase64',
noEnum(function (urisafe) {
return encode(this, urisafe)
})
)
Object.defineProperty(
String.prototype,
'toBase64URI',
noEnum(function () {
return encode(this, true)
})
)
}
}
// that's it!
})(this)
if (this['Meteor']) {
Base64 = global.Base64 // for normal export in Meteor.js
}
;(function (root) {
// body...
var videoajax = function (argument) {
// body...
var host = '/'
this._setHost = function (hoststr) {
host = hoststr
}
this._getHost = function () {
return host
}
}
$.extend(videoajax.prototype, {
/**
* 设置主域名
* @param hosts {String} default: '/' 主域名字符串
*/
setHost: function (hosts) {
this._setHost(hosts)
},
//user_id,course_id,lesson_id,break_point
/**
* 播放退出时间点
* @param data {dataObject} 向后台传递的数据对象,应该包含
* user_id {Number} 用户ID
* course_id {Number} 课程ID
* lesson_id {Number} 课时ID
* break_point {Number} 断点
*@param callback {Function} 回调方法
*/
quitBreakPoint: function (data, callback) {
var paramfiled = ['user_id', 'course_id', 'lesson_id', 'break_point']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=addBreakPoint',
{
userViewPoint: dataStr,
},
function (data) {
isFunction(callback) ? callback(data.code == 200, data) : ''
}
)
},
/**
* user_id:123,course_id:23,lesson_id:3
*播放历史记录
* @param data {dataObject} 向后台传递的数据对象,应该包含
* user_id {Number} 用户ID
* course_id {Number} 课程ID
* lesson_id {Number} 课时ID
*@param callback {Function} 回调方法
*/
getBreakPoint: function (data, callback) {
var paramfiled = ['user_id', 'course_id', 'lesson_id']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=userbreakpoint',
{
userCourseInfo: dataStr,
},
function (data) {
isFunction(callback) ? callback(data.code == 200, data) : ''
// isFunction(callback) ? callback(data) : '';
}
)
},
/**
*用户答题答案
* @param data {dataObject} 向后台传递的数据对象,应该包含
* user_id {Number} 用户ID
* course_id {Number} 课程ID
* lesson_id {Number} 课时ID
* question_order {Number} 问题序号
* answer {String} 问题
*@param callback {Function} 回调方法
*/
getUserAnswer: function (data, callback) {
var paramfiled = ['user_id', 'lesson_id', 'course_id', 'question_order', 'answer']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=addUserAnswer',
{
userAnswerInfo: dataStr,
},
function (data) {
isFunction(callback) ? callback(data.code == 200, data) : ''
}
)
},
/**
*播放结束
* @param data {dataObject} 向后台传递的数据对象,应该包含
* order {Number} 排序ID
* co {Number} 课时ID
*@param callback {Function} 回调方法
*/
finishCourse: function (data, callback) {
var paramfiled = ['order', 'co', 'process']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=user&c=learnrecord&a=playcount',
data,
function (data) {
isFunction(callback) ? callback(data.code == 200, data) : ''
// isFunction(callback) ? callback(data) : '';
}
)
},
/**
*课程进度获取
* @param data {dataObject} 向后台传递的数据对象,应该包含
* order {Number} 排序ID
* co {Number} 课时ID
*@param callback {Function} 回调方法
* 注回调使用的参数,值类似为: {"status":true,"data":{"process":"91"}}
* 或: {"status":false,"data":'参数丢失'}
* 其中status为true为调用正常, 如果有值则data返回json形式的进度, 没有值则返回[]
* status为false时, data保存的是错误提示
*/
getfinishCourse: function (data, callback) {
var paramfiled = ['order', 'co']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=user&c=learnrecord&a=getuserlessionprocess',
data,
function (data) {
isFunction(callback) ? callback(data) : ''
// isFunction(callback) ? callback(data) : '';
}
)
},
/**
*解码加密的breakpoint
* @pparam basecode {String} 要解码的字符串
*/
fromBaseCode: function (basecode) {
return getBreakPoint(basecode)
},
/**
*通过key值和课时值获取自定的数据
* @param data {dataObject} 向后台传递的数据对象,应该包含
* lessonId {Number} 课时ID
* order {Number} 排序ID
*@param callback {Function} 回调方法
*/
getUserDefineInfo: function (data, callback) {
var paramfiled = ['lessonId', 'order']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=getUserDefineInfo',
{
defineDate: dataStr,
},
function (data) {
isFunction(callback) ? callback(data) : ''
}
)
},
/**
*通过key值和课时值及用户ID获取自定的数据
* @param data {dataObject} 向后台传递的数据对象,应该包含
* lessonId {Number} 课时ID
* order {Number} 排序ID
*@param callback {Function} 回调方法
*/
getUserDefineInfoByUser: function (data, callback) {
var paramfiled = ['lessonId', 'order']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=getUserDefineInfoByUser',
{
defineUserDate: dataStr,
},
function (data) {
isFunction(callback) ? callback(data) : ''
}
)
},
/**
*增加自定数据
* @param data {dataObject} 向后台传递的数据对象,应该包含
* lessonId {Number} 课时ID
* key {string} 键值ID
* content {string} 字内容
*@param callback {Function} 回调方法
*/
addUserDefine: function (data, callback) {
var paramfiled = ['lessonId', 'key', 'content']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=addUserDefine',
{
addDefineDate: dataStr,
},
function (data) {
isFunction(callback) ? callback(data) : ''
}
)
},
})
var getDate = function (url, data, callback) {
$.get(
url,
data,
function (data) {
callback(data)
},
'json'
)
}
var isFunction = function (fn) {
if (typeof fn === 'function') return true
return false
}
var verfiyData = function (field, data) {
var arr = []
console.log(field)
console.log('<br/>')
console.log(data)
for (var i in field) {
if (!data[field[i]]) {
throw new Error('data is error')
}
arr.push(field[i] + ':' + data[field[i]])
}
if (root.JSON) {
return JSON.stringify(data)
} else {
return '{' + arr.join(',') + '}'
}
}
var getBreakPoint = function (base64point) {
var newbc = base64point.split('').reverse().join('')
return Base64.fromBase64(newbc)
}
root.videoAjax = function () {
return new videoajax()
}
})(this)
+1
View File
@@ -0,0 +1 @@
(function(global){'use strict';var _Base64=global.Base64;var version="2.1.5";var buffer;if(typeof module!=='undefined'&&module.exports){buffer=require('buffer').Buffer}var b64chars='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';var b64tab=function(bin){var t={};for(var i=0,l=bin.length;i<l;i++)t[bin.charAt(i)]=i;return t}(b64chars);var fromCharCode=String.fromCharCode;var cb_utob=function(c){if(c.length<2){var cc=c.charCodeAt(0);return cc<0x80?c:cc<0x800?(fromCharCode(0xc0|(cc>>>6))+fromCharCode(0x80|(cc&0x3f))):(fromCharCode(0xe0|((cc>>>12)&0x0f))+fromCharCode(0x80|((cc>>>6)&0x3f))+fromCharCode(0x80|(cc&0x3f)))}else{var cc=0x10000+(c.charCodeAt(0)-0xD800)*0x400+(c.charCodeAt(1)-0xDC00);return(fromCharCode(0xf0|((cc>>>18)&0x07))+fromCharCode(0x80|((cc>>>12)&0x3f))+fromCharCode(0x80|((cc>>>6)&0x3f))+fromCharCode(0x80|(cc&0x3f)))}};var re_utob=/[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g;var utob=function(u){return u.replace(re_utob,cb_utob)};var cb_encode=function(ccc){var padlen=[0,2,1][ccc.length%3],ord=ccc.charCodeAt(0)<<16|((ccc.length>1?ccc.charCodeAt(1):0)<<8)|((ccc.length>2?ccc.charCodeAt(2):0)),chars=[b64chars.charAt(ord>>>18),b64chars.charAt((ord>>>12)&63),padlen>=2?'=':b64chars.charAt((ord>>>6)&63),padlen>=1?'=':b64chars.charAt(ord&63)];return chars.join('')};var btoa=global.btoa?function(b){return global.btoa(b)}:function(b){return b.replace(/[\s\S]{1,3}/g,cb_encode)};var _encode=buffer?function(u){return(new buffer(u)).toString('base64')}:function(u){return btoa(utob(u))};var encode=function(u,urisafe){return!urisafe?_encode(u):_encode(u).replace(/[+\/]/g,function(m0){return m0=='+'?'-':'_'}).replace(/=/g,'')};var encodeURI=function(u){return encode(u,true)};var re_btou=new RegExp(['[\xC0-\xDF][\x80-\xBF]','[\xE0-\xEF][\x80-\xBF]{2}','[\xF0-\xF7][\x80-\xBF]{3}'].join('|'),'g');var cb_btou=function(cccc){switch(cccc.length){case 4:var cp=((0x07&cccc.charCodeAt(0))<<18)|((0x3f&cccc.charCodeAt(1))<<12)|((0x3f&cccc.charCodeAt(2))<<6)|(0x3f&cccc.charCodeAt(3)),offset=cp-0x10000;return(fromCharCode((offset>>>10)+0xD800)+fromCharCode((offset&0x3FF)+0xDC00));case 3:return fromCharCode(((0x0f&cccc.charCodeAt(0))<<12)|((0x3f&cccc.charCodeAt(1))<<6)|(0x3f&cccc.charCodeAt(2)));default:return fromCharCode(((0x1f&cccc.charCodeAt(0))<<6)|(0x3f&cccc.charCodeAt(1)))}};var btou=function(b){return b.replace(re_btou,cb_btou)};var cb_decode=function(cccc){var len=cccc.length,padlen=len%4,n=(len>0?b64tab[cccc.charAt(0)]<<18:0)|(len>1?b64tab[cccc.charAt(1)]<<12:0)|(len>2?b64tab[cccc.charAt(2)]<<6:0)|(len>3?b64tab[cccc.charAt(3)]:0),chars=[fromCharCode(n>>>16),fromCharCode((n>>>8)&0xff),fromCharCode(n&0xff)];chars.length-=[0,0,2,1][padlen];return chars.join('')};var atob=global.atob?function(a){return global.atob(a)}:function(a){return a.replace(/[\s\S]{1,4}/g,cb_decode)};var _decode=buffer?function(a){return(new buffer(a,'base64')).toString()}:function(a){return btou(atob(a))};var decode=function(a){return _decode(a.replace(/[-_]/g,function(m0){return m0=='-'?'+':'/'}).replace(/[^A-Za-z0-9\+\/]/g,''))};var noConflict=function(){var Base64=global.Base64;global.Base64=_Base64;return Base64};global.Base64={VERSION:version,atob:atob,btoa:btoa,fromBase64:decode,toBase64:encode,utob:utob,encode:encode,encodeURI:encodeURI,btou:btou,decode:decode,noConflict:noConflict};if(typeof Object.defineProperty==='function'){var noEnum=function(v){return{value:v,enumerable:false,writable:true,configurable:true}};global.Base64.extendString=function(){Object.defineProperty(String.prototype,'fromBase64',noEnum(function(){return decode(this)}));Object.defineProperty(String.prototype,'toBase64',noEnum(function(urisafe){return encode(this,urisafe)}));Object.defineProperty(String.prototype,'toBase64URI',noEnum(function(){return encode(this,true)}))}}})(this);if(this['Meteor']){Base64=global.Base64};(function(root){var videoajax=function(argument){var host='/';this._setHost=function(hoststr){host=hoststr};this._getHost=function(){return host}};$.extend(videoajax.prototype,{setHost:function(hosts){this._setHost(hosts)},quitBreakPoint:function(data,callback){var paramfiled=["user_id","course_id","lesson_id","break_point"];var dataStr=verfiyData(paramfiled,data);getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=addBreakPoint',{userViewPoint:dataStr},function(data){isFunction(callback)?callback(data.code==200,data):''})},getBreakPoint:function(data,callback){var paramfiled=["user_id","course_id","lesson_id"];var dataStr=verfiyData(paramfiled,data);getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=userbreakpoint',{userCourseInfo:dataStr},function(data){isFunction(callback)?callback(data.code==200,data):''})},getUserAnswer:function(data,callback){var paramfiled=["user_id","lesson_id","course_id","question_order","answer"];var dataStr=verfiyData(paramfiled,data);getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=addUserAnswer',{userAnswerInfo:dataStr},function(data){isFunction(callback)?callbacLine truncated
+2 -11
View File
@@ -3,19 +3,10 @@
* 可以根据项目需求使用 axios、fetch 或其他 HTTP 客户端 * 可以根据项目需求使用 axios、fetch 或其他 HTTP 客户端
*/ */
import type { ApiError, ApiResponse } from "./client.types";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api"; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api";
export interface ApiResponse<T> {
data: T;
message?: string;
code?: number;
}
export interface ApiError {
message: string;
code?: number;
}
/** /**
* 基础请求函数 * 基础请求函数
*/ */
+10
View File
@@ -0,0 +1,10 @@
export interface ApiResponse<T> {
data: T;
message?: string;
code?: number;
}
export interface ApiError {
message: string;
code?: number;
}
+2 -9
View File
@@ -1,9 +1,2 @@
export { export { del, get, post, put, request } from "./client.ts";
type ApiError, export type { ApiError, ApiResponse } from "./client.types";
type ApiResponse,
del,
get,
post,
put,
request,
} from "./client.ts";
@@ -1,36 +0,0 @@
@reference "tailwindcss";
.button {
@apply inline-flex items-center justify-center rounded-md font-medium transition-colors;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2;
@apply disabled:pointer-events-none disabled:opacity-50;
}
.primary {
@apply bg-blue-600 text-white hover:bg-blue-700;
}
.secondary {
@apply bg-gray-200 text-gray-900 hover:bg-gray-300;
}
.outline {
@apply border border-gray-300 bg-transparent hover:bg-gray-100;
}
.ghost {
@apply bg-transparent hover:bg-gray-100;
}
.sm {
@apply h-8 px-3 text-sm;
}
.md {
@apply h-10 px-4 text-base;
}
.lg {
@apply h-12 px-6 text-lg;
}
-22
View File
@@ -1,22 +0,0 @@
import { type ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "@/utils";
import styles from "./Button.module.css";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "primary", size = "md", ...props }, ref) => {
return (
<button
ref={ref}
className={cn(styles.button, styles[variant], styles[size], className)}
{...props}
/>
);
},
);
Button.displayName = "Button";
-2
View File
@@ -1,2 +0,0 @@
export type { ButtonProps } from "./Button.tsx";
export { Button } from "./Button.tsx";
@@ -0,0 +1,44 @@
import { appConfig } from "@/app.config";
import { useDialogStore } from "@/stores";
import type {
GuideDialogStep,
StartDialogOptions,
} from "@/stores/types/dialog.types";
export interface DefineDialogOptions {
id: string;
steps: GuideDialogStep[];
showOnce?: boolean;
initialStep?: number;
}
export interface GuideDialogController {
id: string;
steps: GuideDialogStep[];
start: (options?: Pick<StartDialogOptions, "initialStep">) => void;
reset: () => void;
}
export function defineDialog({
id,
steps,
showOnce = true,
initialStep = 0,
}: DefineDialogOptions): GuideDialogController {
return {
id,
steps,
start: (options) => {
useDialogStore.getState().startDialog({
courseId: appConfig.course.id,
dialogId: id,
showOnce,
steps,
initialStep: options?.initialStep ?? initialStep,
});
},
reset: () => {
useDialogStore.getState().resetDialog(appConfig.course.id, id);
},
};
}
@@ -0,0 +1,62 @@
import { useCallback } from "react";
import {
type GuideDialogProps,
GuideDialog as RegistryGuideDialog,
} from "@/components/course-interactions/guide-dialog";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { useDialogStore } from "@/stores";
import { getAppPortalRoot } from "../canvasPortal";
export function GuideDialog({
children,
onFinish,
onSkip,
...props
}: Omit<GuideDialogProps, "actions" | "state" | "viewport"> & {
onFinish?: () => void;
onSkip?: () => void;
}) {
const {
open,
steps,
current,
nextStep,
prevStep,
selectOption,
finishDialog,
skipDialog,
} = useDialogStore();
const { viewport } = useCourseCanvas();
const finish = useCallback(() => {
finishDialog();
onFinish?.();
}, [finishDialog, onFinish]);
const skip = useCallback(() => {
skipDialog();
onSkip?.();
}, [onSkip, skipDialog]);
return (
<RegistryGuideDialog
state={{
open,
steps,
current,
}}
actions={{
next: nextStep,
prev: prevStep,
skip,
finish,
selectOption,
}}
container={getAppPortalRoot}
viewport={viewport}
{...props}
>
{children}
</RegistryGuideDialog>
);
}
@@ -0,0 +1,5 @@
export type {
GuideDialogProps,
GuideDialogRenderState,
GuideDialogTheme,
} from "@/components/course-interactions/guide-dialog";
@@ -0,0 +1,11 @@
export { GuideDialog } from "./GuideDialog";
export type {
DefineDialogOptions,
GuideDialogController,
} from "./GuideDialog.helpers";
export { defineDialog } from "./GuideDialog.helpers";
export type {
GuideDialogProps,
GuideDialogRenderState,
GuideDialogTheme,
} from "./GuideDialog.types";
@@ -0,0 +1,57 @@
import { appConfig } from "@/app.config";
import { useSpotlightStore } from "@/stores";
import type {
SpotlightStep,
StartSpotlightOptions,
} from "@/stores/types/spotlight.types";
type DefineSpotlightStep = Omit<SpotlightStep, "target"> & {
target?: SpotlightStep["target"];
};
export interface DefineSpotlightOptions {
id: string;
steps: DefineSpotlightStep[];
showOnce?: boolean;
initialStep?: number;
}
export interface SpotlightController {
id: string;
steps: SpotlightStep[];
start: (options?: Pick<StartSpotlightOptions, "initialStep">) => void;
reset: () => void;
}
function normalizeSteps(steps: DefineSpotlightStep[]): SpotlightStep[] {
return steps.map((step) => ({
...step,
target: step.target ?? { dataGuide: step.id },
}));
}
export function defineSpotlight({
id,
steps,
showOnce = true,
initialStep = 0,
}: DefineSpotlightOptions): SpotlightController {
const normalizedSteps = normalizeSteps(steps);
return {
id,
steps: normalizedSteps,
start: (options) => {
useSpotlightStore.getState().startSpotlight({
courseId: appConfig.course.id,
spotlightId: id,
showOnce,
steps: normalizedSteps,
initialStep: options?.initialStep ?? initialStep,
});
},
reset: () => {
useSpotlightStore.getState().resetSpotlight(appConfig.course.id, id);
},
};
}
@@ -0,0 +1,62 @@
import { useCallback } from "react";
import {
type GuideSpotlightProps,
GuideSpotlight as RegistryGuideSpotlight,
} from "@/components/course-interactions/guide-spotlight";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { useSpotlightStore } from "@/stores";
import { getAppPortalRoot } from "../canvasPortal";
export function GuideSpotlight({
children,
onFinish,
onSkip,
container = getAppPortalRoot,
viewport,
...props
}: Omit<GuideSpotlightProps, "actions" | "state"> & {
onFinish?: () => void;
onSkip?: () => void;
}) {
const {
open,
steps,
current,
nextStep,
prevStep,
finishSpotlight,
skipSpotlight,
} = useSpotlightStore();
const { viewport: canvasViewport } = useCourseCanvas();
const finish = useCallback(() => {
finishSpotlight();
onFinish?.();
}, [finishSpotlight, onFinish]);
const skip = useCallback(() => {
skipSpotlight();
onSkip?.();
}, [onSkip, skipSpotlight]);
return (
<RegistryGuideSpotlight
state={{
open,
steps,
current,
}}
actions={{
next: nextStep,
prev: prevStep,
skip,
finish,
}}
container={container}
viewport={viewport ?? canvasViewport}
{...props}
>
{children}
</RegistryGuideSpotlight>
);
}
@@ -0,0 +1,7 @@
export type {
GuideSpotlightProps,
GuideSpotlightRenderState,
GuideSpotlightTheme,
Rect,
SpotlightLayout,
} from "@/components/course-interactions/guide-spotlight";
@@ -0,0 +1,11 @@
export { GuideSpotlight } from "./GuideSpotlight";
export type {
DefineSpotlightOptions,
SpotlightController,
} from "./GuideSpotlight.helpers";
export { defineSpotlight } from "./GuideSpotlight.helpers";
export type {
GuideSpotlightProps,
GuideSpotlightRenderState,
GuideSpotlightTheme,
} from "./GuideSpotlight.types";
@@ -0,0 +1,25 @@
export function LandscapePrompt() {
return (
<div className="fixed inset-0 z-2000 flex items-center justify-center bg-gray-950/80 px-8 text-white">
<div className="text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center">
<svg
aria-hidden="true"
className="h-full w-full text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<rect x="2" y="5" width="20" height="14" rx="2" />
<circle cx="12" cy="12" r="1" fill="currentColor" />
</svg>
</div>
<h1 className="text-2xl font-semibold">请旋转屏幕</h1>
<p className="mt-3 text-base text-gray-400">
为获得最佳体验,请横屏浏览
</p>
</div>
</div>
);
}
@@ -0,0 +1 @@
export { LandscapePrompt } from "./LandscapePrompt";
@@ -0,0 +1,39 @@
import { isRouteErrorResponse, Link, useRouteError } from "react-router-dom";
export function RouteError() {
const error = useRouteError();
const message = isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: error instanceof Error
? error.message
: "页面加载失败";
return (
<div className="flex min-h-full items-center justify-center p-8">
<div className="w-full max-w-xl rounded-3xl border border-rose-200 bg-white p-8 text-center shadow-xl">
<p className="text-sm font-semibold text-rose-600">页面运行异常</p>
<h1 className="mt-3 text-2xl font-bold text-gray-950">
当前示例无法加载
</h1>
<p className="mt-3 break-words text-sm leading-6 text-gray-600">
{message}
</p>
<div className="mt-6 flex justify-center gap-3">
<Link
to="/"
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white"
>
返回首页
</Link>
<button
type="button"
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700"
onClick={() => window.location.reload()}
>
重新加载
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,7 @@
export function RouteFallback() {
return (
<div className="flex min-h-[200px] items-center justify-center text-gray-500">
加载中...
</div>
);
}
@@ -0,0 +1,2 @@
export { RouteError } from "./RouteError";
export { RouteFallback } from "./RouteFallback";
+16
View File
@@ -0,0 +1,16 @@
import type { RefObject } from "react";
let portalRoot: RefObject<HTMLDivElement | null> | null = null;
export function registerAppPortalRoot(
container: RefObject<HTMLDivElement | null>,
): () => void {
portalRoot = container;
return () => {
if (portalRoot === container) portalRoot = null;
};
}
export function getAppPortalRoot(): HTMLDivElement | null {
return portalRoot?.current ?? null;
}
+35 -2
View File
@@ -1,2 +1,35 @@
export type { ButtonProps } from "./Button"; export type { ButtonProps } from "@/components/ui/button";
export { Button } from "./Button"; export { Button } from "@/components/ui/button";
export type { OverlayProps } from "@/components/ui/overlay";
export { Overlay } from "@/components/ui/overlay";
export type { PortalContainer, PortalProps } from "@/components/ui/portal";
export { Portal } from "@/components/ui/portal";
export type {
ToastContextValue,
ToastItem as ToastItemData,
ToastOptions,
ToastProviderProps,
ToastRenderApi,
ToastType,
ToastViewportClassNames,
ToastViewportProps,
} from "@/components/ui/toast";
export { ToastProvider, ToastViewport, useToast } from "@/components/ui/toast";
export type {
DefineDialogOptions,
GuideDialogController,
GuideDialogProps,
GuideDialogRenderState,
GuideDialogTheme,
} from "./GuideDialog";
export { defineDialog, GuideDialog } from "./GuideDialog";
export type {
DefineSpotlightOptions,
GuideSpotlightProps,
GuideSpotlightRenderState,
GuideSpotlightTheme,
SpotlightController,
} from "./GuideSpotlight";
export { defineSpotlight, GuideSpotlight } from "./GuideSpotlight";
export { LandscapePrompt } from "./LandscapePrompt";
export { RouteError, RouteFallback } from "./RouteFallback";
-1
View File
@@ -1 +0,0 @@
export { useLocalStorage } from "./useLocalStorage.ts";
-52
View File
@@ -1,52 +0,0 @@
import { useEffect, useState } from "react";
/**
* 自定义 Hook:使用 localStorage 存储状态
* @param key - localStorage 的 key
* @param initialValue - 初始值
* @returns [storedValue, setValue] - 存储的值和更新函数
*/
export function useLocalStorage<T>(
key: string,
initialValue: T,
): [T, (value: T | ((val: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
try {
setStoredValue(JSON.parse(e.newValue) as T);
} catch (error) {
console.error(`Error parsing storage event for key "${key}":`, error);
}
}
};
window.addEventListener("storage", handleStorageChange);
return () => {
window.removeEventListener("storage", handleStorageChange);
};
}, [key]);
return [storedValue, setValue];
}
+17
View File
@@ -0,0 +1,17 @@
export * from "./modules";
export type {
AppState,
GuideDialogCharacter,
GuideDialogOption,
GuideDialogPlacement,
GuideDialogState,
GuideDialogStep,
SpotlightPlacement,
SpotlightState,
SpotlightStep,
SpotlightTarget,
StartDialogOptions,
StartSpotlightOptions,
UserState,
YibanConnectionStatus,
} from "./types";
+8
View File
@@ -0,0 +1,8 @@
import { create } from "zustand";
import type { AppState } from "../types/app.types";
export const useAppStore = create<AppState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
reset: () => set({ count: 0 }),
}));
+136
View File
@@ -0,0 +1,136 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { appConfig } from "@/app.config";
import type { GuideDialogState } from "../types/dialog.types";
const getDialogKey = (courseId: string, dialogId: string) =>
`${courseId}:${dialogId}`;
const getInitialState = () => ({
open: false,
courseId: null,
dialogId: null,
steps: [],
current: 0,
showOnce: true,
});
export const useDialogStore = create<GuideDialogState>()(
persist(
(set, get) => ({
...getInitialState(),
completedDialogMap: {},
startDialog: ({
courseId,
dialogId,
steps,
showOnce = true,
initialStep = 0,
}) => {
if (steps.length === 0) return;
const key = getDialogKey(courseId, dialogId);
if (showOnce && get().completedDialogMap[key]) {
return;
}
set({
open: true,
courseId,
dialogId,
steps,
current: Math.min(Math.max(initialStep, 0), steps.length - 1),
showOnce,
});
},
closeDialog: () => {
set(getInitialState());
},
nextStep: () => {
const { current, steps, finishDialog } = get();
if (current >= steps.length - 1) {
finishDialog();
return;
}
set({ current: current + 1 });
},
prevStep: () => {
const { current } = get();
set({ current: Math.max(current - 1, 0) });
},
setStep: (index) => {
const { steps } = get();
if (steps.length === 0) return;
set({ current: Math.min(Math.max(index, 0), steps.length - 1) });
},
selectOption: (optionId) => {
const { current, steps, setStep, finishDialog, nextStep } = get();
const step = steps[current];
const option = step?.options?.find((item) => item.id === optionId);
if (!option) return;
if (option.finish) {
finishDialog();
return;
}
if (option.nextId) {
const nextIndex = steps.findIndex(
(item) => item.id === option.nextId,
);
if (nextIndex >= 0) {
setStep(nextIndex);
return;
}
}
nextStep();
},
finishDialog: () => {
const { courseId, dialogId, completedDialogMap } = get();
if (!courseId || !dialogId) {
set(getInitialState());
return;
}
const key = getDialogKey(courseId, dialogId);
set({
...getInitialState(),
completedDialogMap: {
...completedDialogMap,
[key]: true,
},
});
},
skipDialog: () => {
get().finishDialog();
},
resetDialog: (courseId, dialogId) => {
const key = getDialogKey(courseId, dialogId);
const { [key]: _removed, ...rest } = get().completedDialogMap;
const isCurrentDialog =
get().courseId === courseId && get().dialogId === dialogId;
set({
...(isCurrentDialog ? getInitialState() : {}),
completedDialogMap: rest,
});
},
}),
{
name: `${appConfig.storage.prefix}-dialog-storage`,
partialize: (state) => ({
completedDialogMap: state.completedDialogMap,
}),
},
),
);
+4
View File
@@ -0,0 +1,4 @@
export * from "./app";
export * from "./dialog";
export * from "./spotlight";
export * from "./user";
+112
View File
@@ -0,0 +1,112 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { appConfig } from "@/app.config";
import type { SpotlightState } from "../types/spotlight.types";
const getSpotlightKey = (courseId: string, spotlightId: string) =>
`${courseId}:${spotlightId}`;
const getInitialState = () => ({
open: false,
courseId: null,
spotlightId: null,
steps: [],
current: 0,
showOnce: true,
});
export const useSpotlightStore = create<SpotlightState>()(
persist(
(set, get) => ({
...getInitialState(),
completedSpotlightMap: {},
startSpotlight: ({
courseId,
spotlightId,
steps,
showOnce = true,
initialStep = 0,
}) => {
if (steps.length === 0) return;
const key = getSpotlightKey(courseId, spotlightId);
if (showOnce && get().completedSpotlightMap[key]) {
return;
}
set({
open: true,
courseId,
spotlightId,
steps,
current: Math.min(Math.max(initialStep, 0), steps.length - 1),
showOnce,
});
},
closeSpotlight: () => {
set(getInitialState());
},
nextStep: () => {
const { current, steps, finishSpotlight } = get();
if (current >= steps.length - 1) {
finishSpotlight();
return;
}
set({ current: current + 1 });
},
prevStep: () => {
const { current } = get();
set({ current: Math.max(current - 1, 0) });
},
setStep: (index) => {
const { steps } = get();
if (steps.length === 0) return;
set({ current: Math.min(Math.max(index, 0), steps.length - 1) });
},
finishSpotlight: () => {
const { courseId, spotlightId, completedSpotlightMap } = get();
if (!courseId || !spotlightId) {
set(getInitialState());
return;
}
const key = getSpotlightKey(courseId, spotlightId);
set({
...getInitialState(),
completedSpotlightMap: {
...completedSpotlightMap,
[key]: true,
},
});
},
skipSpotlight: () => {
get().finishSpotlight();
},
resetSpotlight: (courseId, spotlightId) => {
const key = getSpotlightKey(courseId, spotlightId);
const { [key]: _removed, ...rest } = get().completedSpotlightMap;
const isCurrentSpotlight =
get().courseId === courseId && get().spotlightId === spotlightId;
set({
...(isCurrentSpotlight ? getInitialState() : {}),
completedSpotlightMap: rest,
});
},
}),
{
name: `${appConfig.storage.prefix}-spotlight-storage`,
partialize: (state) => ({
completedSpotlightMap: state.completedSpotlightMap,
}),
},
),
);
+29
View File
@@ -0,0 +1,29 @@
import { create } from "zustand";
import type { UserState } from "../types/user.types";
export const useUserStore = create<UserState>((set) => ({
yibanConnectionStatus: "unknown",
setYibanConnectionStatus: (status) => set({ yibanConnectionStatus: status }),
courseId: null,
setCourseId: (courseId) => set({ courseId }),
userId: null,
userName: null,
userAvatar: null,
setUserInfo: (info) =>
set({
userId: info.userId,
userName: info.userName ?? null,
userAvatar: info.userAvatar ?? null,
}),
clearUser: () =>
set({
userId: null,
userName: null,
userAvatar: null,
yibanConnectionStatus: "unknown",
courseId: null,
}),
}));
+5
View File
@@ -0,0 +1,5 @@
export interface AppState {
count: number;
increment: () => void;
reset: () => void;
}
+58
View File
@@ -0,0 +1,58 @@
import type { ReactNode } from "react";
export type GuideDialogPlacement = "top" | "center" | "bottom";
export interface GuideDialogCharacter {
name?: ReactNode;
avatar?: ReactNode;
mood?: string;
}
export interface GuideDialogOption {
id: string;
label: ReactNode;
nextId?: string;
finish?: boolean;
data?: unknown;
}
export interface GuideDialogStep {
id: string;
character?: GuideDialogCharacter;
title?: ReactNode;
description?: ReactNode;
placement?: GuideDialogPlacement;
nextLabel?: string;
prevLabel?: string;
skipLabel?: string;
doneLabel?: string;
options?: GuideDialogOption[];
data?: unknown;
}
export interface StartDialogOptions {
courseId: string;
dialogId: string;
steps: GuideDialogStep[];
showOnce?: boolean;
initialStep?: number;
}
export interface GuideDialogState {
open: boolean;
courseId: string | null;
dialogId: string | null;
steps: GuideDialogStep[];
current: number;
showOnce: boolean;
completedDialogMap: Record<string, boolean>;
startDialog: (options: StartDialogOptions) => void;
closeDialog: () => void;
nextStep: () => void;
prevStep: () => void;
setStep: (index: number) => void;
selectOption: (optionId: string) => void;
finishDialog: () => void;
skipDialog: () => void;
resetDialog: (courseId: string, dialogId: string) => void;
}
+17
View File
@@ -0,0 +1,17 @@
export type { AppState } from "./app.types";
export type {
GuideDialogCharacter,
GuideDialogOption,
GuideDialogPlacement,
GuideDialogState,
GuideDialogStep,
StartDialogOptions,
} from "./dialog.types";
export type {
SpotlightPlacement,
SpotlightState,
SpotlightStep,
SpotlightTarget,
StartSpotlightOptions,
} from "./spotlight.types";
export type { UserState, YibanConnectionStatus } from "./user.types";
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
export type SpotlightPlacement = "top" | "bottom" | "left" | "right" | "auto";
export interface SpotlightTarget {
selector?: string;
dataGuide?: string;
}
export interface SpotlightStep {
id: string;
target: string | SpotlightTarget;
title?: ReactNode;
description?: ReactNode;
placement?: SpotlightPlacement;
allowTargetClick?: boolean;
padding?: number;
radius?: number;
panelClassName?: string;
nextLabel?: string;
prevLabel?: string;
doneLabel?: string;
skipLabel?: string;
data?: unknown;
}
export interface StartSpotlightOptions {
courseId: string;
spotlightId: string;
steps: SpotlightStep[];
showOnce?: boolean;
initialStep?: number;
}
export interface SpotlightState {
open: boolean;
courseId: string | null;
spotlightId: string | null;
steps: SpotlightStep[];
current: number;
showOnce: boolean;
completedSpotlightMap: Record<string, boolean>;
startSpotlight: (options: StartSpotlightOptions) => void;
closeSpotlight: () => void;
nextStep: () => void;
prevStep: () => void;
setStep: (index: number) => void;
finishSpotlight: () => void;
skipSpotlight: () => void;
resetSpotlight: (courseId: string, spotlightId: string) => void;
}
+24
View File
@@ -0,0 +1,24 @@
export type YibanConnectionStatus = "connected" | "disconnected" | "unknown";
export interface UserState {
// 易班连接状态
yibanConnectionStatus: YibanConnectionStatus;
setYibanConnectionStatus: (status: YibanConnectionStatus) => void;
// 课程ID(用于上报进度)
courseId: string | null;
setCourseId: (courseId: string) => void;
// 易班用户信息
userId: string | null;
userName: string | null;
userAvatar: string | null;
setUserInfo: (info: {
userId: string;
userName?: string;
userAvatar?: string;
}) => void;
// 清空用户信息
clearUser: () => void;
}
-34
View File
@@ -1,34 +0,0 @@
/**
* 全局类型定义
*/
// 用户相关类型
export interface User {
id: string;
name: string;
email: string;
avatar?: string;
}
// API 响应类型
export interface PaginationParams {
page: number;
pageSize: number;
}
export interface PaginationResponse<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
// 通用类型
export type Nullable<T> = T | null;
export type Optional<T> = T | undefined;
// 组件 Props 基础类型
export interface BaseComponentProps {
className?: string;
children?: React.ReactNode;
}
+137
View File
@@ -0,0 +1,137 @@
/**
* 易班 videoajax.js 相关类型定义
*/
// 声明外部依赖(由 vite.config.ts 注入的 videoajax.js 提供,运行时可能加载失败)
declare global {
var videoAjax: (() => VideoAjax) | undefined;
}
/**
* VideoAjax 实例接口
*/
export interface VideoAjax {
setHost(host: string): void;
addUserDefine(
param: UserDefineParam,
callback?: (data: unknown) => void,
): void;
getUserDefineInfoByUser(
param: UserDefineParam,
callback: (data: VideoAjaxResponse | undefined) => void,
): void;
getUserDefineInfo(
param: UserDefineParam,
callback: (data: VideoAjaxResponse | undefined) => void,
): void;
finishCourse(
param: { order: number; co: string; process: number },
callback?: (status: boolean, data: unknown) => void,
): void;
getfinishCourse(
param: { order: number; co: string },
callback: (data: unknown) => void,
): void;
quitBreakPoint(
param: {
user_id: string;
course_id: string;
lesson_id: string;
break_point: number;
},
callback?: (status: boolean, data: unknown) => void,
): void;
getBreakPoint(
param: { user_id: string; course_id: string; lesson_id: string },
callback: (status: boolean, data: unknown) => void,
): void;
getUserAnswer(
param: {
user_id: string;
lesson_id: string;
course_id: string;
question_order: number;
answer: string;
},
callback?: (status: boolean, data: unknown) => void,
): void;
}
/**
* UserDefine 接口参数
*/
export interface UserDefineParam {
lessonId: string;
key?: string | number;
order?: string | number;
content?: string;
}
/**
* VideoAjax 响应项
*/
export interface VideoAjaxResponseItem {
content?: string;
}
/**
* VideoAjax 响应
*/
export interface VideoAjaxResponse {
code: number;
msg?: string;
data: {
value?: VideoAjaxResponseItem[] | string;
process?: string;
};
}
/**
* 用户存储获取状态
*/
export type UserStorageFetchStatus =
| "success"
| "empty"
| "unauthenticated"
| "error";
/**
* 用户存储获取结果
*/
export interface UserStorageFetchResult {
status: UserStorageFetchStatus;
content: string;
code?: number;
message?: string;
}
/**
* 存储后端类型
*/
export type StorageBackend = "auto" | "yiban" | "session";
/**
* 存储选项
*/
export interface StorageOptions {
backend?: StorageBackend;
}
/**
* 易班请求结果
*/
export interface YibanRequestResult<T = unknown> {
success: boolean;
data?: T;
error?: string;
}
/**
* 播放断点参数
*/
export interface BreakPointParams {
userId: string;
courseId: string;
lessonId: string;
breakPoint?: number;
}
+3
View File
@@ -1,3 +1,6 @@
export * from "./assets"; export * from "./assets";
export * from "./lazyRoute"; export * from "./lazyRoute";
export * from "./tailwind"; export * from "./tailwind";
export * from "./useOrientation";
export * from "./useScale";
export * from "./yiban";
+1 -10
View File
@@ -1,10 +1 @@
import { twMerge } from "tailwind-merge"; export { cn } from "@/lib/cn";
/**
* 合并 Tailwind CSS 类名,自动处理冲突
* @param classes - 要合并的类名
* @returns 合并后的类名字符串
*/
export function cn(...classes: (string | undefined | null | false)[]): string {
return twMerge(classes.filter(Boolean).join(" "));
}
+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;
}