|
|
@@ -0,0 +1,423 @@
|
|
|
+import React, { useState, useEffect, useRef, createContext, useContext } from 'react';
|
|
|
+import AliRtcEngine, { AliRtcSubscribeState, AliRtcVideoTrack, AliRtcSdkChannelProfile } from 'aliyun-rtc-sdk';
|
|
|
+import { ToastContainer, toast } from 'react-toastify';
|
|
|
+
|
|
|
+// 从 SDK 中提取需要的类型
|
|
|
+type ImEngine = InstanceType<typeof AliVCInteraction.ImEngine>;
|
|
|
+type ImGroupManager = AliVCInteraction.AliVCIMGroupManager;
|
|
|
+type ImMessageManager = AliVCInteraction.AliVCIMMessageManager;
|
|
|
+type ImLogLevel = AliVCInteraction.ImLogLevel;
|
|
|
+type ImMessageLevel = AliVCInteraction.ImMessageLevel;
|
|
|
+
|
|
|
+interface ImUser {
|
|
|
+ userId: string;
|
|
|
+ userExtension?: string;
|
|
|
+}
|
|
|
+
|
|
|
+interface ImGroupMessage {
|
|
|
+ groupId: string;
|
|
|
+ type: number;
|
|
|
+ data: string;
|
|
|
+ sender?: ImUser;
|
|
|
+ timestamp?: number;
|
|
|
+}
|
|
|
+
|
|
|
+// 课堂上下文类型
|
|
|
+type ClassroomContextType = {
|
|
|
+ userId: string;
|
|
|
+ role: 'teacher' | 'student';
|
|
|
+ isLoggedIn: boolean;
|
|
|
+ isJoinedClass: boolean;
|
|
|
+ messageList: string[];
|
|
|
+ errorMessage: string;
|
|
|
+ setRole: (role: 'teacher' | 'student') => void;
|
|
|
+};
|
|
|
+
|
|
|
+const ClassroomContext = createContext<ClassroomContextType | null>(null);
|
|
|
+
|
|
|
+// 辅助函数
|
|
|
+function hex(buffer: ArrayBuffer): string {
|
|
|
+ const hexCodes = [];
|
|
|
+ const view = new DataView(buffer);
|
|
|
+ for (let i = 0; i < view.byteLength; i += 4) {
|
|
|
+ const value = view.getUint32(i);
|
|
|
+ const stringValue = value.toString(16);
|
|
|
+ const padding = '00000000';
|
|
|
+ const paddedValue = (padding + stringValue).slice(-padding.length);
|
|
|
+ hexCodes.push(paddedValue);
|
|
|
+ }
|
|
|
+ return hexCodes.join('');
|
|
|
+}
|
|
|
+
|
|
|
+async function generateToken(
|
|
|
+ appId: string,
|
|
|
+ appKey: string,
|
|
|
+ channelId: string,
|
|
|
+ userId: string,
|
|
|
+ timestamp: number
|
|
|
+): Promise<string> {
|
|
|
+ const encoder = new TextEncoder();
|
|
|
+ const data = encoder.encode(`${appId}${appKey}${channelId}${userId}${timestamp}`);
|
|
|
+ const hash = await crypto.subtle.digest('SHA-256', data);
|
|
|
+ return hex(hash);
|
|
|
+}
|
|
|
+
|
|
|
+function showToast(type: 'info' | 'success' | 'error', message: string): void {
|
|
|
+ switch(type) {
|
|
|
+ case 'info':
|
|
|
+ toast.info(message);
|
|
|
+ break;
|
|
|
+ case 'success':
|
|
|
+ toast.success(message);
|
|
|
+ break;
|
|
|
+ case 'error':
|
|
|
+ toast.error(message);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 从SDK获取枚举值
|
|
|
+const { ImLogLevel, ImMessageLevel } = window.AliVCInteraction;
|
|
|
+
|
|
|
+// 配置信息
|
|
|
+const IM_APP_ID = '4c2ab5e1b1b0';
|
|
|
+const IM_APP_KEY = '314bb5eee5b623549e8a41574ba3ff32';
|
|
|
+const IM_APP_SIGN = 'H4sIAAAAAAAE/wCQAG//zguHB+lYCilkv7diSkk4GmcvLuds+InRu9vFOFebMwm/jEgsK5bBT85Z0owObMxG58uXHyPFlPEBEDQm9FswNJ+KmX0VDYkcfdPPWkafA6Hc0B6F+p5De9yJfPEfHzwo/DHMaygbHfLmBgUtmKveq421sJr/gNBz9D04Ewsg39us+ao0NegzLt7xtXvFXXXJAAAA//8BAAD//yoav6aQAAAA';
|
|
|
+const RTC_APP_ID = 'a5842c2a-d94a-43be-81de-1fdb712476e1';
|
|
|
+const RTC_APP_KEY = 'b71d65f4f84c450f6f058f4ad507bd42';
|
|
|
+
|
|
|
+export const ClassroomPage = () => {
|
|
|
+ // 状态管理
|
|
|
+ const [userId, setUserId] = useState<string>('');
|
|
|
+ const [role, setRole] = useState<'teacher' | 'student'>('student');
|
|
|
+ const [classId, setClassId] = useState<string>('');
|
|
|
+ const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
|
|
|
+ const [isJoinedClass, setIsJoinedClass] = useState<boolean>(false);
|
|
|
+ const [msgText, setMsgText] = useState<string>('');
|
|
|
+ const [messageList, setMessageList] = useState<string[]>([]);
|
|
|
+ const [errorMessage, setErrorMessage] = useState<string>('');
|
|
|
+
|
|
|
+ // SDK实例
|
|
|
+ const imEngine = useRef<ImEngine | null>(null);
|
|
|
+ const imGroupManager = useRef<ImGroupManager | null>(null);
|
|
|
+ const imMessageManager = useRef<ImMessageManager | null>(null);
|
|
|
+ const aliRtcEngine = useRef<AliRtcEngine | null>(null);
|
|
|
+ const remoteVideoElMap = useRef<Record<string, HTMLVideoElement>>({});
|
|
|
+ const remoteVideoContainer = useRef<HTMLDivElement>(null);
|
|
|
+
|
|
|
+ // 消息管理模块
|
|
|
+ const showMessage = (text: string): void => {
|
|
|
+ setMessageList([...messageList, text]);
|
|
|
+ };
|
|
|
+
|
|
|
+ const listenImEvents = (): void => {
|
|
|
+ if (!imEngine.current) return;
|
|
|
+
|
|
|
+ imEngine.current.on('connectsuccess', () => {
|
|
|
+ showMessage('IM连接成功');
|
|
|
+ });
|
|
|
+
|
|
|
+ imEngine.current.on('disconnect', (code: number) => {
|
|
|
+ showMessage(`IM断开连接: ${code}`);
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const listenGroupEvents = (): void => {
|
|
|
+ if (!imGroupManager.current) return;
|
|
|
+
|
|
|
+ imGroupManager.current.on('memberchange', (groupId: string, memberCount: number, joinUsers: ImUser[], leaveUsers: ImUser[]) => {
|
|
|
+ showMessage(`成员变更: 加入${joinUsers.length}人, 离开${leaveUsers.length}人`);
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const listenMessageEvents = (): void => {
|
|
|
+ if (!imMessageManager.current) return;
|
|
|
+
|
|
|
+ imMessageManager.current.on('recvgroupmessage', (msg: AliVCInteraction.ImMessage, groupId: string) => {
|
|
|
+ showMessage(`收到消息: ${msg.data}`);
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ // 音视频模块
|
|
|
+ const removeRemoteVideo = (userId: string, type: 'camera' | 'screen' = 'camera') => {
|
|
|
+ const vid = `${type}_${userId}`;
|
|
|
+ const el = remoteVideoElMap.current[vid];
|
|
|
+ if (el) {
|
|
|
+ aliRtcEngine.current!.setRemoteViewConfig(null, userId, type === 'camera' ? AliRtcVideoTrack.AliRtcVideoTrackCamera : AliRtcVideoTrack.AliRtcVideoTrackScreen);
|
|
|
+ el.pause();
|
|
|
+ remoteVideoContainer.current?.removeChild(el);
|
|
|
+ delete remoteVideoElMap.current[vid];
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const listenRtcEvents = () => {
|
|
|
+ if (!aliRtcEngine.current) return;
|
|
|
+
|
|
|
+ aliRtcEngine.current.on('remoteUserOnLineNotify', (userId: string) => {
|
|
|
+ showMessage(`用户 ${userId} 加入课堂`);
|
|
|
+ });
|
|
|
+
|
|
|
+ aliRtcEngine.current.on('remoteUserOffLineNotify', (userId: string) => {
|
|
|
+ showMessage(`用户 ${userId} 离开课堂`);
|
|
|
+ removeRemoteVideo(userId, 'camera');
|
|
|
+ removeRemoteVideo(userId, 'screen');
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ // 统一登录逻辑
|
|
|
+ const login = async (userId: string): Promise<void> => {
|
|
|
+ try {
|
|
|
+ // 初始化IM
|
|
|
+ const { ImEngine: ImEngineClass } = window.AliVCInteraction;
|
|
|
+ imEngine.current = ImEngineClass.createEngine();
|
|
|
+ await imEngine.current.init({
|
|
|
+ deviceId: 'xxxx',
|
|
|
+ appId: IM_APP_ID,
|
|
|
+ appSign: IM_APP_SIGN,
|
|
|
+ logLevel: ImLogLevel.ERROR,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 初始化RTC
|
|
|
+ aliRtcEngine.current = AliRtcEngine.getInstance();
|
|
|
+ AliRtcEngine.setLogLevel(0);
|
|
|
+
|
|
|
+ // 设置事件监听
|
|
|
+ listenImEvents();
|
|
|
+ listenRtcEvents();
|
|
|
+
|
|
|
+ setIsLoggedIn(true);
|
|
|
+ setErrorMessage('');
|
|
|
+ showToast('success', '登录成功');
|
|
|
+ } catch (err: any) {
|
|
|
+ setErrorMessage(`登录失败: ${err.message}`);
|
|
|
+ showToast('error', '登录失败');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 加入课堂
|
|
|
+ const joinClass = async (classId: string): Promise<void> => {
|
|
|
+ if (!imEngine.current || !aliRtcEngine.current) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 加入IM群组
|
|
|
+ const gm = imEngine.current.getGroupManager();
|
|
|
+ const mm = imEngine.current.getMessageManager();
|
|
|
+ imGroupManager.current = gm || null;
|
|
|
+ imMessageManager.current = mm || null;
|
|
|
+ await gm!.joinGroup(classId);
|
|
|
+ listenGroupEvents();
|
|
|
+ listenMessageEvents();
|
|
|
+
|
|
|
+ // 加入RTC频道
|
|
|
+ const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
|
|
|
+ const token = await generateToken(RTC_APP_ID, RTC_APP_KEY, classId, userId, timestamp);
|
|
|
+ aliRtcEngine.current.setChannelProfile(AliRtcSdkChannelProfile.AliRtcSdkCommunication);
|
|
|
+ await aliRtcEngine.current.joinChannel(
|
|
|
+ {
|
|
|
+ channelId: classId,
|
|
|
+ userId,
|
|
|
+ appId: RTC_APP_ID,
|
|
|
+ token,
|
|
|
+ timestamp,
|
|
|
+ },
|
|
|
+ userId
|
|
|
+ );
|
|
|
+
|
|
|
+ // 设置本地预览
|
|
|
+ aliRtcEngine.current.setLocalViewConfig('localPreviewer', AliRtcVideoTrack.AliRtcVideoTrackCamera);
|
|
|
+
|
|
|
+ setIsJoinedClass(true);
|
|
|
+ setErrorMessage('');
|
|
|
+ showToast('success', '加入课堂成功');
|
|
|
+ } catch (err: any) {
|
|
|
+ setErrorMessage(`加入课堂失败: ${err.message}`);
|
|
|
+ showToast('error', '加入课堂失败');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 离开课堂
|
|
|
+ const leaveClass = async (): Promise<void> => {
|
|
|
+ if (imGroupManager.current && classId) {
|
|
|
+ await imGroupManager.current.leaveGroup(classId);
|
|
|
+ }
|
|
|
+ if (aliRtcEngine.current) {
|
|
|
+ await aliRtcEngine.current.leaveChannel();
|
|
|
+ }
|
|
|
+
|
|
|
+ setIsJoinedClass(false);
|
|
|
+ showToast('info', '已离开课堂');
|
|
|
+ };
|
|
|
+
|
|
|
+ // 发送消息
|
|
|
+ const sendMessage = async (): Promise<void> => {
|
|
|
+ if (!imMessageManager.current || !classId) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ await imMessageManager.current.sendGroupMessage({
|
|
|
+ groupId: classId,
|
|
|
+ data: msgText,
|
|
|
+ type: 88888,
|
|
|
+ level: ImMessageLevel.NORMAL,
|
|
|
+ });
|
|
|
+ setMsgText('');
|
|
|
+ setErrorMessage('');
|
|
|
+ } catch (err: any) {
|
|
|
+ setErrorMessage(`消息发送失败: ${err.message}`);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 清理资源
|
|
|
+ useEffect(() => {
|
|
|
+ return () => {
|
|
|
+ if (imGroupManager.current) {
|
|
|
+ imGroupManager.current.removeAllListeners();
|
|
|
+ }
|
|
|
+ if (imMessageManager.current) {
|
|
|
+ imMessageManager.current.removeAllListeners();
|
|
|
+ }
|
|
|
+ if (imEngine.current) {
|
|
|
+ imEngine.current.removeAllListeners();
|
|
|
+ }
|
|
|
+ if (aliRtcEngine.current) {
|
|
|
+ aliRtcEngine.current.destroy();
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <ClassroomContext.Provider value={{
|
|
|
+ userId,
|
|
|
+ role,
|
|
|
+ isLoggedIn,
|
|
|
+ isJoinedClass,
|
|
|
+ messageList,
|
|
|
+ errorMessage,
|
|
|
+ setRole,
|
|
|
+ }}>
|
|
|
+ <div className="container mx-auto p-4">
|
|
|
+ <h1 className="text-2xl font-bold mb-4">互动课堂</h1>
|
|
|
+
|
|
|
+ <ToastContainer
|
|
|
+ position="top-right"
|
|
|
+ autoClose={5000}
|
|
|
+ hideProgressBar={false}
|
|
|
+ newestOnTop={false}
|
|
|
+ closeOnClick
|
|
|
+ rtl={false}
|
|
|
+ pauseOnFocusLoss
|
|
|
+ draggable
|
|
|
+ pauseOnHover
|
|
|
+ />
|
|
|
+
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
|
+ <div className="md:col-span-1">
|
|
|
+ <form>
|
|
|
+ <div className="mb-2">
|
|
|
+ <label className="block text-sm font-medium text-gray-700">用户ID</label>
|
|
|
+ <input
|
|
|
+ className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
|
|
|
+ value={userId}
|
|
|
+ onChange={(e) => setUserId(e.target.value)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="mb-2">
|
|
|
+ <label className="block text-sm font-medium text-gray-700">课堂ID</label>
|
|
|
+ <input
|
|
|
+ className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
|
|
|
+ value={classId}
|
|
|
+ onChange={(e) => setClassId(e.target.value)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="mb-2">
|
|
|
+ <label className="block text-sm font-medium text-gray-700">角色</label>
|
|
|
+ <select
|
|
|
+ className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
|
|
|
+ value={role}
|
|
|
+ onChange={(e) => setRole(e.target.value as 'teacher' | 'student')}
|
|
|
+ >
|
|
|
+ <option value="student">学生</option>
|
|
|
+ <option value="teacher">老师</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex space-x-2 mb-2">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="px-3 py-2 bg-blue-600 text-white rounded-md"
|
|
|
+ disabled={isLoggedIn}
|
|
|
+ onClick={() => login(userId)}
|
|
|
+ >
|
|
|
+ 登录
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="px-3 py-2 bg-blue-600 text-white rounded-md"
|
|
|
+ disabled={!isLoggedIn || isJoinedClass}
|
|
|
+ onClick={() => joinClass(classId)}
|
|
|
+ >
|
|
|
+ 加入课堂
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="px-3 py-2 bg-gray-600 text-white rounded-md"
|
|
|
+ disabled={!isJoinedClass}
|
|
|
+ onClick={leaveClass}
|
|
|
+ >
|
|
|
+ 离开课堂
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </form>
|
|
|
+
|
|
|
+ <div className="mt-4">
|
|
|
+ <label className="block text-sm font-medium text-gray-700">消息</label>
|
|
|
+ <input
|
|
|
+ className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
|
|
|
+ value={msgText}
|
|
|
+ onChange={(e) => setMsgText(e.target.value)}
|
|
|
+ />
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="mt-2 px-3 py-2 bg-blue-600 text-white rounded-md"
|
|
|
+ disabled={!isJoinedClass}
|
|
|
+ onClick={sendMessage}
|
|
|
+ >
|
|
|
+ 发送
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="md:col-span-1">
|
|
|
+ <h4 className="text-lg font-medium mb-2">消息记录</h4>
|
|
|
+ <div className="bg-gray-100 p-2 rounded-md h-64 overflow-y-auto">
|
|
|
+ {messageList.map((msg, i) => (
|
|
|
+ <div key={i} className="mb-1">{msg}</div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="md:col-span-1">
|
|
|
+ <h4 className="text-lg font-medium mb-2">视频区域</h4>
|
|
|
+ <video
|
|
|
+ id="localPreviewer"
|
|
|
+ muted
|
|
|
+ className="w-full h-48 bg-black mb-2"
|
|
|
+ ></video>
|
|
|
+ <div
|
|
|
+ id="remoteVideoContainer"
|
|
|
+ ref={remoteVideoContainer}
|
|
|
+ className="grid grid-cols-2 gap-2"
|
|
|
+ ></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {errorMessage && (
|
|
|
+ <div className="mt-2 text-red-500">{errorMessage}</div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </ClassroomContext.Provider>
|
|
|
+ );
|
|
|
+};
|