pages_classroom.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. import React, { useState, useEffect, useRef, createContext, useContext } from 'react';
  2. import AliRtcEngine, { AliRtcSubscribeState, AliRtcVideoTrack, AliRtcSdkChannelProfile } from 'aliyun-rtc-sdk';
  3. import { ToastContainer, toast } from 'react-toastify';
  4. // 从 SDK 中提取需要的类型
  5. type ImEngine = InstanceType<typeof AliVCInteraction.ImEngine>;
  6. type ImGroupManager = AliVCInteraction.AliVCIMGroupManager;
  7. type ImMessageManager = AliVCInteraction.AliVCIMMessageManager;
  8. type ImLogLevel = AliVCInteraction.ImLogLevel;
  9. type ImMessageLevel = AliVCInteraction.ImMessageLevel;
  10. interface ImUser {
  11. userId: string;
  12. userExtension?: string;
  13. }
  14. interface ImGroupMessage {
  15. groupId: string;
  16. type: number;
  17. data: string;
  18. sender?: ImUser;
  19. timestamp?: number;
  20. }
  21. // 课堂上下文类型
  22. type ClassroomContextType = {
  23. userId: string;
  24. role: 'teacher' | 'student';
  25. isLoggedIn: boolean;
  26. isJoinedClass: boolean;
  27. messageList: string[];
  28. errorMessage: string;
  29. setRole: (role: 'teacher' | 'student') => void;
  30. };
  31. const ClassroomContext = createContext<ClassroomContextType | null>(null);
  32. // 辅助函数
  33. function hex(buffer: ArrayBuffer): string {
  34. const hexCodes = [];
  35. const view = new DataView(buffer);
  36. for (let i = 0; i < view.byteLength; i += 4) {
  37. const value = view.getUint32(i);
  38. const stringValue = value.toString(16);
  39. const padding = '00000000';
  40. const paddedValue = (padding + stringValue).slice(-padding.length);
  41. hexCodes.push(paddedValue);
  42. }
  43. return hexCodes.join('');
  44. }
  45. async function generateToken(
  46. appId: string,
  47. appKey: string,
  48. channelId: string,
  49. userId: string,
  50. timestamp: number
  51. ): Promise<string> {
  52. const encoder = new TextEncoder();
  53. const data = encoder.encode(`${appId}${appKey}${channelId}${userId}${timestamp}`);
  54. const hash = await crypto.subtle.digest('SHA-256', data);
  55. return hex(hash);
  56. }
  57. function showToast(type: 'info' | 'success' | 'error', message: string): void {
  58. switch(type) {
  59. case 'info':
  60. toast.info(message);
  61. break;
  62. case 'success':
  63. toast.success(message);
  64. break;
  65. case 'error':
  66. toast.error(message);
  67. break;
  68. }
  69. }
  70. // 从SDK获取枚举值
  71. const { ImLogLevel, ImMessageLevel } = window.AliVCInteraction;
  72. // 配置信息
  73. const IM_APP_ID = '4c2ab5e1b1b0';
  74. const IM_APP_KEY = '314bb5eee5b623549e8a41574ba3ff32';
  75. const IM_APP_SIGN = 'H4sIAAAAAAAE/wCQAG//zguHB+lYCilkv7diSkk4GmcvLuds+InRu9vFOFebMwm/jEgsK5bBT85Z0owObMxG58uXHyPFlPEBEDQm9FswNJ+KmX0VDYkcfdPPWkafA6Hc0B6F+p5De9yJfPEfHzwo/DHMaygbHfLmBgUtmKveq421sJr/gNBz9D04Ewsg39us+ao0NegzLt7xtXvFXXXJAAAA//8BAAD//yoav6aQAAAA';
  76. const RTC_APP_ID = 'a5842c2a-d94a-43be-81de-1fdb712476e1';
  77. const RTC_APP_KEY = 'b71d65f4f84c450f6f058f4ad507bd42';
  78. export const ClassroomPage = () => {
  79. // 状态管理
  80. const [userId, setUserId] = useState<string>('');
  81. const [role, setRole] = useState<'teacher' | 'student'>('student');
  82. const [classId, setClassId] = useState<string>('');
  83. const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
  84. const [isJoinedClass, setIsJoinedClass] = useState<boolean>(false);
  85. const [msgText, setMsgText] = useState<string>('');
  86. const [messageList, setMessageList] = useState<string[]>([]);
  87. const [errorMessage, setErrorMessage] = useState<string>('');
  88. // SDK实例
  89. const imEngine = useRef<ImEngine | null>(null);
  90. const imGroupManager = useRef<ImGroupManager | null>(null);
  91. const imMessageManager = useRef<ImMessageManager | null>(null);
  92. const aliRtcEngine = useRef<AliRtcEngine | null>(null);
  93. const remoteVideoElMap = useRef<Record<string, HTMLVideoElement>>({});
  94. const remoteVideoContainer = useRef<HTMLDivElement>(null);
  95. // 消息管理模块
  96. const showMessage = (text: string): void => {
  97. setMessageList([...messageList, text]);
  98. };
  99. const listenImEvents = (): void => {
  100. if (!imEngine.current) return;
  101. imEngine.current.on('connectsuccess', () => {
  102. showMessage('IM连接成功');
  103. });
  104. imEngine.current.on('disconnect', (code: number) => {
  105. showMessage(`IM断开连接: ${code}`);
  106. });
  107. };
  108. const listenGroupEvents = (): void => {
  109. if (!imGroupManager.current) return;
  110. imGroupManager.current.on('memberchange', (groupId: string, memberCount: number, joinUsers: ImUser[], leaveUsers: ImUser[]) => {
  111. showMessage(`成员变更: 加入${joinUsers.length}人, 离开${leaveUsers.length}人`);
  112. });
  113. };
  114. const listenMessageEvents = (): void => {
  115. if (!imMessageManager.current) return;
  116. imMessageManager.current.on('recvgroupmessage', (msg: AliVCInteraction.ImMessage, groupId: string) => {
  117. showMessage(`收到消息: ${msg.data}`);
  118. });
  119. };
  120. // 音视频模块
  121. const removeRemoteVideo = (userId: string, type: 'camera' | 'screen' = 'camera') => {
  122. const vid = `${type}_${userId}`;
  123. const el = remoteVideoElMap.current[vid];
  124. if (el) {
  125. aliRtcEngine.current!.setRemoteViewConfig(null, userId, type === 'camera' ? AliRtcVideoTrack.AliRtcVideoTrackCamera : AliRtcVideoTrack.AliRtcVideoTrackScreen);
  126. el.pause();
  127. remoteVideoContainer.current?.removeChild(el);
  128. delete remoteVideoElMap.current[vid];
  129. }
  130. };
  131. const listenRtcEvents = () => {
  132. if (!aliRtcEngine.current) return;
  133. aliRtcEngine.current.on('remoteUserOnLineNotify', (userId: string) => {
  134. showMessage(`用户 ${userId} 加入课堂`);
  135. });
  136. aliRtcEngine.current.on('remoteUserOffLineNotify', (userId: string) => {
  137. showMessage(`用户 ${userId} 离开课堂`);
  138. removeRemoteVideo(userId, 'camera');
  139. removeRemoteVideo(userId, 'screen');
  140. });
  141. };
  142. // 统一登录逻辑
  143. const login = async (userId: string): Promise<void> => {
  144. try {
  145. // 初始化IM
  146. const { ImEngine: ImEngineClass } = window.AliVCInteraction;
  147. imEngine.current = ImEngineClass.createEngine();
  148. await imEngine.current.init({
  149. deviceId: 'xxxx',
  150. appId: IM_APP_ID,
  151. appSign: IM_APP_SIGN,
  152. logLevel: ImLogLevel.ERROR,
  153. });
  154. // 初始化RTC
  155. aliRtcEngine.current = AliRtcEngine.getInstance();
  156. AliRtcEngine.setLogLevel(0);
  157. // 设置事件监听
  158. listenImEvents();
  159. listenRtcEvents();
  160. setIsLoggedIn(true);
  161. setErrorMessage('');
  162. showToast('success', '登录成功');
  163. } catch (err: any) {
  164. setErrorMessage(`登录失败: ${err.message}`);
  165. showToast('error', '登录失败');
  166. }
  167. };
  168. // 加入课堂
  169. const joinClass = async (classId: string): Promise<void> => {
  170. if (!imEngine.current || !aliRtcEngine.current) return;
  171. try {
  172. // 加入IM群组
  173. const gm = imEngine.current.getGroupManager();
  174. const mm = imEngine.current.getMessageManager();
  175. imGroupManager.current = gm || null;
  176. imMessageManager.current = mm || null;
  177. await gm!.joinGroup(classId);
  178. listenGroupEvents();
  179. listenMessageEvents();
  180. // 加入RTC频道
  181. const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
  182. const token = await generateToken(RTC_APP_ID, RTC_APP_KEY, classId, userId, timestamp);
  183. aliRtcEngine.current.setChannelProfile(AliRtcSdkChannelProfile.AliRtcSdkCommunication);
  184. await aliRtcEngine.current.joinChannel(
  185. {
  186. channelId: classId,
  187. userId,
  188. appId: RTC_APP_ID,
  189. token,
  190. timestamp,
  191. },
  192. userId
  193. );
  194. // 设置本地预览
  195. aliRtcEngine.current.setLocalViewConfig('localPreviewer', AliRtcVideoTrack.AliRtcVideoTrackCamera);
  196. setIsJoinedClass(true);
  197. setErrorMessage('');
  198. showToast('success', '加入课堂成功');
  199. } catch (err: any) {
  200. setErrorMessage(`加入课堂失败: ${err.message}`);
  201. showToast('error', '加入课堂失败');
  202. }
  203. };
  204. // 离开课堂
  205. const leaveClass = async (): Promise<void> => {
  206. if (imGroupManager.current && classId) {
  207. await imGroupManager.current.leaveGroup(classId);
  208. }
  209. if (aliRtcEngine.current) {
  210. await aliRtcEngine.current.leaveChannel();
  211. }
  212. setIsJoinedClass(false);
  213. showToast('info', '已离开课堂');
  214. };
  215. // 发送消息
  216. const sendMessage = async (): Promise<void> => {
  217. if (!imMessageManager.current || !classId) return;
  218. try {
  219. await imMessageManager.current.sendGroupMessage({
  220. groupId: classId,
  221. data: msgText,
  222. type: 88888,
  223. level: ImMessageLevel.NORMAL,
  224. });
  225. setMsgText('');
  226. setErrorMessage('');
  227. } catch (err: any) {
  228. setErrorMessage(`消息发送失败: ${err.message}`);
  229. }
  230. };
  231. // 清理资源
  232. useEffect(() => {
  233. return () => {
  234. if (imGroupManager.current) {
  235. imGroupManager.current.removeAllListeners();
  236. }
  237. if (imMessageManager.current) {
  238. imMessageManager.current.removeAllListeners();
  239. }
  240. if (imEngine.current) {
  241. imEngine.current.removeAllListeners();
  242. }
  243. if (aliRtcEngine.current) {
  244. aliRtcEngine.current.destroy();
  245. }
  246. };
  247. }, []);
  248. return (
  249. <ClassroomContext.Provider value={{
  250. userId,
  251. role,
  252. isLoggedIn,
  253. isJoinedClass,
  254. messageList,
  255. errorMessage,
  256. setRole,
  257. }}>
  258. <div className="container mx-auto p-4">
  259. <h1 className="text-2xl font-bold mb-4">互动课堂</h1>
  260. <ToastContainer
  261. position="top-right"
  262. autoClose={5000}
  263. hideProgressBar={false}
  264. newestOnTop={false}
  265. closeOnClick
  266. rtl={false}
  267. pauseOnFocusLoss
  268. draggable
  269. pauseOnHover
  270. />
  271. <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
  272. <div className="md:col-span-1">
  273. <form>
  274. <div className="mb-2">
  275. <label className="block text-sm font-medium text-gray-700">用户ID</label>
  276. <input
  277. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  278. value={userId}
  279. onChange={(e) => setUserId(e.target.value)}
  280. />
  281. </div>
  282. <div className="mb-2">
  283. <label className="block text-sm font-medium text-gray-700">课堂ID</label>
  284. <input
  285. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  286. value={classId}
  287. onChange={(e) => setClassId(e.target.value)}
  288. />
  289. </div>
  290. <div className="mb-2">
  291. <label className="block text-sm font-medium text-gray-700">角色</label>
  292. <select
  293. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  294. value={role}
  295. onChange={(e) => setRole(e.target.value as 'teacher' | 'student')}
  296. >
  297. <option value="student">学生</option>
  298. <option value="teacher">老师</option>
  299. </select>
  300. </div>
  301. <div className="flex space-x-2 mb-2">
  302. <button
  303. type="button"
  304. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  305. disabled={isLoggedIn}
  306. onClick={() => login(userId)}
  307. >
  308. 登录
  309. </button>
  310. <button
  311. type="button"
  312. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  313. disabled={!isLoggedIn || isJoinedClass}
  314. onClick={() => joinClass(classId)}
  315. >
  316. 加入课堂
  317. </button>
  318. <button
  319. type="button"
  320. className="px-3 py-2 bg-gray-600 text-white rounded-md"
  321. disabled={!isJoinedClass}
  322. onClick={leaveClass}
  323. >
  324. 离开课堂
  325. </button>
  326. </div>
  327. </form>
  328. <div className="mt-4">
  329. <label className="block text-sm font-medium text-gray-700">消息</label>
  330. <input
  331. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  332. value={msgText}
  333. onChange={(e) => setMsgText(e.target.value)}
  334. />
  335. <button
  336. type="button"
  337. className="mt-2 px-3 py-2 bg-blue-600 text-white rounded-md"
  338. disabled={!isJoinedClass}
  339. onClick={sendMessage}
  340. >
  341. 发送
  342. </button>
  343. </div>
  344. </div>
  345. <div className="md:col-span-1">
  346. <h4 className="text-lg font-medium mb-2">消息记录</h4>
  347. <div className="bg-gray-100 p-2 rounded-md h-64 overflow-y-auto">
  348. {messageList.map((msg, i) => (
  349. <div key={i} className="mb-1">{msg}</div>
  350. ))}
  351. </div>
  352. </div>
  353. <div className="md:col-span-1">
  354. <h4 className="text-lg font-medium mb-2">视频区域</h4>
  355. <video
  356. id="localPreviewer"
  357. muted
  358. className="w-full h-48 bg-black mb-2"
  359. ></video>
  360. <div
  361. id="remoteVideoContainer"
  362. ref={remoteVideoContainer}
  363. className="grid grid-cols-2 gap-2"
  364. ></div>
  365. </div>
  366. </div>
  367. {errorMessage && (
  368. <div className="mt-2 text-red-500">{errorMessage}</div>
  369. )}
  370. </div>
  371. </ClassroomContext.Provider>
  372. );
  373. };