pages_classroom.tsx 17 KB

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