pages_classroom.tsx 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121
  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. enum Role {
  22. Teacher = 'admin',
  23. Student = 'student'
  24. }
  25. // 课堂状态枚举
  26. enum ClassStatus {
  27. NOT_STARTED = 'not_started',
  28. IN_PROGRESS = 'in_progress',
  29. ENDED = 'ended'
  30. }
  31. // 课堂上下文类型
  32. // 互动消息类型
  33. type InteractionAction = 'hand_up' | 'cancel_hand_up' | 'answer_hand_up';
  34. // 互动消息基础接口
  35. interface InteractionMessage {
  36. action: InteractionAction;
  37. studentId: string;
  38. studentName?: string;
  39. timestamp?: number;
  40. question?: string;
  41. }
  42. // 举手请求类型
  43. interface HandUpRequest extends InteractionMessage {
  44. timestamp: number;
  45. }
  46. type ClassroomContextType = {
  47. userId: string;
  48. role: Role;
  49. isLoggedIn: boolean;
  50. isJoinedClass: boolean;
  51. messageList: string[];
  52. errorMessage: string;
  53. classStatus: ClassStatus;
  54. handUpList: HandUpRequest[]; // 举手列表
  55. questions: string[]; // 问题列表
  56. setRole: (role: Role) => void;
  57. createClass: (className: string, maxMembers?: number) => Promise<string | null>; // 创建课堂
  58. startClass: () => Promise<void>;
  59. endClass: () => Promise<void>;
  60. toggleMuteMember: (userId: string, mute: boolean) => Promise<void>;
  61. handUp: (question?: string) => Promise<void>; // 学生举手
  62. answerHandUp: (studentId: string) => Promise<void>; // 老师应答
  63. sendQuestion: (question: string) => Promise<void>; // 发送问题
  64. };
  65. const ClassroomContext = createContext<ClassroomContextType | null>(null);
  66. // 辅助函数
  67. function hex(buffer: ArrayBuffer): string {
  68. const hexCodes = [];
  69. const view = new DataView(buffer);
  70. for (let i = 0; i < view.byteLength; i += 4) {
  71. const value = view.getUint32(i);
  72. const stringValue = value.toString(16);
  73. const padding = '00000000';
  74. const paddedValue = (padding + stringValue).slice(-padding.length);
  75. hexCodes.push(paddedValue);
  76. }
  77. return hexCodes.join('');
  78. }
  79. async function generateToken(
  80. appId: string,
  81. appKey: string,
  82. channelId: string,
  83. userId: string,
  84. timestamp: number
  85. ): Promise<string> {
  86. const encoder = new TextEncoder();
  87. const data = encoder.encode(`${appId}${appKey}${channelId}${userId}${timestamp}`);
  88. const hash = await crypto.subtle.digest('SHA-256', data);
  89. return hex(hash);
  90. }
  91. function showToast(type: 'info' | 'success' | 'error', message: string): void {
  92. switch(type) {
  93. case 'info':
  94. toast.info(message);
  95. break;
  96. case 'success':
  97. toast.success(message);
  98. break;
  99. case 'error':
  100. toast.error(message);
  101. break;
  102. }
  103. }
  104. // 从SDK获取枚举值
  105. const { ImLogLevel, ImMessageLevel } = window.AliVCInteraction;
  106. // 配置信息
  107. const IM_APP_ID = '4c2ab5e1b1b0';
  108. const IM_APP_KEY = '314bb5eee5b623549e8a41574ba3ff32';
  109. const IM_APP_SIGN = 'H4sIAAAAAAAE/wCQAG//zguHB+lYCilkv7diSkk4GmcvLuds+InRu9vFOFebMwm/jEgsK5bBT85Z0owObMxG58uXHyPFlPEBEDQm9FswNJ+KmX0VDYkcfdPPWkafA6Hc0B6F+p5De9yJfPEfHzwo/DHMaygbHfLmBgUtmKveq421sJr/gNBz9D04Ewsg39us+ao0NegzLt7xtXvFXXXJAAAA//8BAAD//yoav6aQAAAA';
  110. const RTC_APP_ID = 'a5842c2a-d94a-43be-81de-1fdb712476e1';
  111. const RTC_APP_KEY = 'b71d65f4f84c450f6f058f4ad507bd42';
  112. // IM Token生成
  113. async function generateImToken(userId: string, role: string): Promise<string> {
  114. const nonce = 'AK_4';
  115. const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
  116. const pendingShaStr = `${IM_APP_ID}${IM_APP_KEY}${userId}${nonce}${timestamp}${role}`;
  117. const encoder = new TextEncoder();
  118. const data = encoder.encode(pendingShaStr);
  119. const hash = await crypto.subtle.digest('SHA-256', data);
  120. return hex(hash);
  121. }
  122. export const ClassroomPage = () => {
  123. // 解析URL参数
  124. useEffect(() => {
  125. const queryParams = new URLSearchParams(window.location.search);
  126. const urlClassId = queryParams.get('classId');
  127. if (urlClassId) {
  128. setClassId(urlClassId);
  129. showMessage(`从分享链接获取课堂ID: ${urlClassId}`);
  130. }
  131. }, []);
  132. // 状态管理
  133. const [userId, setUserId] = useState<string>('');
  134. const [isCameraOn, setIsCameraOn] = useState<boolean>(true);
  135. const [className, setClassName] = useState<string>('');
  136. const [role, setRole] = useState<Role>(Role.Student);
  137. const [classId, setClassId] = useState<string>('');
  138. const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
  139. const [isJoinedClass, setIsJoinedClass] = useState<boolean>(false);
  140. const [msgText, setMsgText] = useState<string>('');
  141. const [messageList, setMessageList] = useState<string[]>([]);
  142. const [errorMessage, setErrorMessage] = useState<string>('');
  143. const [classStatus, setClassStatus] = useState<ClassStatus>(ClassStatus.NOT_STARTED);
  144. const [handUpList, setHandUpList] = useState<HandUpRequest[]>([]);
  145. const [questions, setQuestions] = useState<string[]>([]);
  146. const [students, setStudents] = useState<Array<{id: string, name: string}>>([]);
  147. const [shareLink, setShareLink] = useState<string>('');
  148. // SDK实例
  149. const imEngine = useRef<ImEngine | null>(null);
  150. const imGroupManager = useRef<ImGroupManager | null>(null);
  151. const imMessageManager = useRef<ImMessageManager | null>(null);
  152. const aliRtcEngine = useRef<AliRtcEngine | null>(null);
  153. const remoteVideoElMap = useRef<Record<string, HTMLVideoElement>>({});
  154. const remoteVideoContainer = useRef<HTMLDivElement>(null);
  155. // 消息管理模块
  156. const showMessage = (text: string): void => {
  157. setMessageList([...messageList, text]);
  158. };
  159. const listenImEvents = (): void => {
  160. if (!imEngine.current) return;
  161. imEngine.current.on('connectsuccess', () => {
  162. showMessage('IM连接成功');
  163. });
  164. imEngine.current.on('disconnect', async (code: number) => {
  165. showMessage(`IM断开连接: ${code}`);
  166. // 自动重连
  167. try {
  168. const imToken = await generateImToken(userId, role);
  169. await imEngine.current!.login({
  170. user: {
  171. userId,
  172. userExtension: '{}'
  173. },
  174. userAuth: {
  175. nonce: 'AK_4',
  176. timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
  177. token: imToken,
  178. role
  179. }
  180. });
  181. showMessage('IM自动重连成功');
  182. } catch (err: unknown) {
  183. const error = err as Error;
  184. showMessage(`IM自动重连失败: ${error.message}`);
  185. }
  186. });
  187. };
  188. const listenGroupEvents = (): void => {
  189. if (!imGroupManager.current) return;
  190. imGroupManager.current.on('memberchange', (groupId: string, memberCount: number, joinUsers: ImUser[], leaveUsers: ImUser[]) => {
  191. showMessage(`成员变更: 加入${joinUsers.length}人, 离开${leaveUsers.length}人`);
  192. });
  193. };
  194. const listenMessageEvents = (): void => {
  195. if (!imMessageManager.current) return;
  196. imMessageManager.current.on('recvgroupmessage', (msg: AliVCInteraction.ImMessage, groupId: string) => {
  197. if (msg.type === 88889) { // 课堂状态消息
  198. try {
  199. const data = JSON.parse(msg.data);
  200. if (data.action === 'start_class') {
  201. setClassStatus(ClassStatus.IN_PROGRESS);
  202. showMessage('老师已开始上课');
  203. } else if (data.action === 'end_class') {
  204. setClassStatus(ClassStatus.ENDED);
  205. showMessage('老师已结束上课');
  206. }
  207. } catch (err) {
  208. console.error('解析课堂状态消息失败', err);
  209. }
  210. } else if (msg.type === 88890) { // 静音指令
  211. try {
  212. const data = JSON.parse(msg.data);
  213. if (data.action === 'toggle_mute' && data.userId === userId) {
  214. showMessage(data.mute ? '你已被老师静音' : '老师已取消你的静音');
  215. }
  216. } catch (err) {
  217. console.error('解析静音指令失败', err);
  218. }
  219. } else if (msg.type === 88891) { // 举手消息
  220. try {
  221. const data = JSON.parse(msg.data) as InteractionMessage;
  222. if (data.action === 'hand_up') {
  223. const handUpData: HandUpRequest = {
  224. ...data,
  225. timestamp: data.timestamp || Date.now()
  226. };
  227. setHandUpList([...handUpList, handUpData]);
  228. showMessage(`${data.studentName || data.studentId} 举手了`);
  229. } else if (data.action === 'cancel_hand_up') {
  230. setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
  231. }
  232. } catch (err) {
  233. console.error('解析举手消息失败', err);
  234. }
  235. } else if (msg.type === 88892) { // 问题消息
  236. try {
  237. const data = JSON.parse(msg.data) as {question: string};
  238. setQuestions([...questions, data.question]);
  239. showMessage(`收到问题: ${data.question}`);
  240. } catch (err) {
  241. console.error('解析问题消息失败', err);
  242. }
  243. } else if (msg.type === 88893) { // 应答消息
  244. try {
  245. const data = JSON.parse(msg.data) as InteractionMessage;
  246. if (data.action === 'answer_hand_up' && data.studentId === userId) {
  247. showMessage('老师已应答你的举手');
  248. setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
  249. }
  250. } catch (err) {
  251. console.error('解析应答消息失败', err);
  252. }
  253. }
  254. });
  255. };
  256. // 音视频模块
  257. const removeRemoteVideo = (userId: string, type: 'camera' | 'screen' = 'camera') => {
  258. const vid = `${type}_${userId}`;
  259. const el = remoteVideoElMap.current[vid];
  260. if (el) {
  261. aliRtcEngine.current!.setRemoteViewConfig(null, userId, type === 'camera' ? AliRtcVideoTrack.AliRtcVideoTrackCamera : AliRtcVideoTrack.AliRtcVideoTrackScreen);
  262. el.pause();
  263. remoteVideoContainer.current?.removeChild(el);
  264. delete remoteVideoElMap.current[vid];
  265. }
  266. };
  267. const listenRtcEvents = () => {
  268. if (!aliRtcEngine.current) return;
  269. showMessage('注册rtc事件监听')
  270. aliRtcEngine.current.on('remoteUserOnLineNotify', (userId: string) => {
  271. showMessage(`用户 ${userId} 加入课堂`);
  272. console.log('用户上线通知:', userId);
  273. });
  274. aliRtcEngine.current.on('remoteUserOffLineNotify', (userId: string) => {
  275. showMessage(`用户 ${userId} 离开课堂`);
  276. console.log('用户下线通知:', userId);
  277. removeRemoteVideo(userId, 'camera');
  278. removeRemoteVideo(userId, 'screen');
  279. });
  280. // 订阅所有用户视频流
  281. aliRtcEngine.current.on('videoSubscribeStateChanged', (
  282. userId: string,
  283. oldState: AliRtcSubscribeState,
  284. newState: AliRtcSubscribeState,
  285. interval: number,
  286. channelId: string
  287. ) => {
  288. console.log(`视频订阅状态变化: 用户 ${userId}, 旧状态 ${oldState}, 新状态 ${newState}`);
  289. switch(newState) {
  290. case 3: // 订阅成功
  291. try {
  292. console.log('开始创建远程视频元素');
  293. // 检查是否已有该用户的视频元素
  294. if (remoteVideoElMap.current[`camera_${userId}`]) {
  295. console.log(`用户 ${userId} 的视频元素已存在`);
  296. return;
  297. }
  298. const video = document.createElement('video');
  299. video.autoplay = true;
  300. video.playsInline = true;
  301. video.className = 'w-80 h-45 mr-2 mb-2 bg-black';
  302. if (!remoteVideoContainer.current) {
  303. console.error('远程视频容器未找到');
  304. return;
  305. }
  306. // 确保容器可见
  307. remoteVideoContainer.current.style.display = 'block';
  308. remoteVideoContainer.current.appendChild(video);
  309. remoteVideoElMap.current[`camera_${userId}`] = video;
  310. // 设置远程视图配置
  311. aliRtcEngine.current!.setRemoteViewConfig(
  312. video,
  313. userId,
  314. AliRtcVideoTrack.AliRtcVideoTrackCamera
  315. );
  316. console.log(`已订阅用户 ${userId} 的视频流`);
  317. showMessage(`已显示用户 ${userId} 的视频`);
  318. } catch (err) {
  319. console.error(`订阅用户 ${userId} 视频流失败:`, err);
  320. showMessage(`订阅用户 ${userId} 视频流失败`);
  321. }
  322. break;
  323. case 1: // 取消订阅
  324. console.log(`取消订阅用户 ${userId} 的视频流`);
  325. removeRemoteVideo(userId, 'camera');
  326. break;
  327. case 2: // 订阅中
  328. console.log(`正在订阅用户 ${userId} 的视频流...`);
  329. break;
  330. default:
  331. console.warn(`未知订阅状态: ${newState}`);
  332. }
  333. });
  334. };
  335. // 获取学生列表
  336. const fetchStudents = async (classId: string) => {
  337. try {
  338. if (!imEngine.current) {
  339. throw new Error('IM引擎未初始化');
  340. }
  341. const groupManager = imEngine.current.getGroupManager();
  342. if (!groupManager) {
  343. throw new Error('IM群组管理器未初始化');
  344. }
  345. // 使用classId作为群组ID获取成员
  346. const response = await groupManager.listRecentGroupUser(classId);
  347. // 转换IM用户数据格式
  348. const students = response.userList.map((user: ImUser) => ({
  349. id: user.userId,
  350. name: user.userExtension || `用户${user.userId}`
  351. }));
  352. setStudents(students);
  353. } catch (err) {
  354. console.error('从IM获取学生列表失败:', err);
  355. // 可选: 显示错误提示给用户
  356. // setError('获取学生列表失败,请稍后重试');
  357. }
  358. };
  359. // 统一登录逻辑
  360. const login = async (userId: string): Promise<void> => {
  361. try {
  362. // 初始化IM
  363. const { ImEngine: ImEngineClass } = window.AliVCInteraction;
  364. imEngine.current = ImEngineClass.createEngine();
  365. await imEngine.current.init({
  366. deviceId: 'xxxx',
  367. appId: IM_APP_ID,
  368. appSign: IM_APP_SIGN,
  369. logLevel: ImLogLevel.ERROR,
  370. });
  371. // 登录IM
  372. const imToken = await generateImToken(userId, role);
  373. await imEngine.current.login({
  374. user: {
  375. userId,
  376. userExtension: '{}'
  377. },
  378. userAuth: {
  379. nonce: 'AK_4',
  380. timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
  381. token: imToken,
  382. role
  383. }
  384. });
  385. // 初始化RTC
  386. aliRtcEngine.current = AliRtcEngine.getInstance();
  387. AliRtcEngine.setLogLevel(0);
  388. // 设置事件监听
  389. listenImEvents();
  390. listenRtcEvents();
  391. setIsLoggedIn(true);
  392. setErrorMessage('');
  393. showToast('success', '登录成功');
  394. // 登录成功,不生成分享链接(将在课堂创建成功后生成)
  395. } catch (err: any) {
  396. setErrorMessage(`登录失败: ${err.message}`);
  397. showToast('error', '登录失败');
  398. }
  399. };
  400. // 加入课堂
  401. const joinClass = async (classId: string): Promise<void> => {
  402. if (!imEngine.current || !aliRtcEngine.current) return;
  403. if (!classId) {
  404. setErrorMessage('课堂ID不能为空');
  405. showToast('error', '请输入有效的课堂ID');
  406. return;
  407. }
  408. try {
  409. // 加入IM群组
  410. const gm = imEngine.current.getGroupManager();
  411. const mm = imEngine.current.getMessageManager();
  412. imGroupManager.current = gm || null;
  413. imMessageManager.current = mm || null;
  414. await gm!.joinGroup(classId);
  415. listenGroupEvents();
  416. listenMessageEvents();
  417. listenRtcEvents();
  418. // 加入RTC频道
  419. const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
  420. const token = await generateToken(RTC_APP_ID, RTC_APP_KEY, classId, userId, timestamp);
  421. aliRtcEngine.current.setChannelProfile(AliRtcSdkChannelProfile.AliRtcSdkCommunication);
  422. await aliRtcEngine.current.joinChannel(
  423. {
  424. channelId: classId,
  425. userId,
  426. appId: RTC_APP_ID,
  427. token,
  428. timestamp,
  429. },
  430. userId
  431. );
  432. // 设置本地预览
  433. aliRtcEngine.current.setLocalViewConfig('localPreviewer', AliRtcVideoTrack.AliRtcVideoTrackCamera);
  434. // 保留基础RTC连接,视频发布逻辑已移至startClass
  435. setIsJoinedClass(true);
  436. setErrorMessage('');
  437. showToast('success', '加入课堂成功');
  438. } catch (err: any) {
  439. setErrorMessage(`加入课堂失败: ${err.message}`);
  440. showToast('error', '加入课堂失败');
  441. }
  442. };
  443. // 离开课堂
  444. const leaveClass = async (): Promise<void> => {
  445. if (imGroupManager.current && classId) {
  446. await imGroupManager.current.leaveGroup(classId);
  447. }
  448. if (aliRtcEngine.current) {
  449. await aliRtcEngine.current.leaveChannel();
  450. }
  451. setIsJoinedClass(false);
  452. showToast('info', '已离开课堂');
  453. };
  454. // 发送消息
  455. const sendMessage = async (): Promise<void> => {
  456. if (!imMessageManager.current || !classId) return;
  457. try {
  458. await imMessageManager.current.sendGroupMessage({
  459. groupId: classId,
  460. data: msgText,
  461. type: 88888,
  462. level: ImMessageLevel.NORMAL,
  463. });
  464. setMsgText('');
  465. setErrorMessage('');
  466. } catch (err: any) {
  467. setErrorMessage(`消息发送失败: ${err.message}`);
  468. }
  469. };
  470. // 开始上课
  471. const startClass = async (): Promise<void> => {
  472. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  473. try {
  474. // 确保RTC连接已建立
  475. if (!aliRtcEngine.current) {
  476. throw new Error('RTC连接未建立');
  477. }
  478. // 加入RTC频道
  479. const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
  480. const token = await generateToken(RTC_APP_ID, RTC_APP_KEY, classId, userId, timestamp);
  481. aliRtcEngine.current.setChannelProfile(AliRtcSdkChannelProfile.AliRtcSdkCommunication);
  482. await aliRtcEngine.current.joinChannel(
  483. {
  484. channelId: classId,
  485. userId,
  486. appId: RTC_APP_ID,
  487. token,
  488. timestamp,
  489. },
  490. userId
  491. );
  492. // 开启老师视频
  493. try {
  494. aliRtcEngine.current!.setLocalViewConfig('localPreviewer', AliRtcVideoTrack.AliRtcVideoTrackCamera);
  495. await aliRtcEngine.current.startPreview();
  496. console.log('老师视频已开启');
  497. } catch (err) {
  498. console.error('开启老师视频失败:', err);
  499. showToast('error', '开启视频失败');
  500. throw err;
  501. }
  502. // 发送开始上课消息
  503. await imMessageManager.current.sendGroupMessage({
  504. groupId: classId,
  505. data: JSON.stringify({ action: 'start_class' }),
  506. type: 88889, // 自定义消息类型
  507. level: ImMessageLevel.HIGH,
  508. });
  509. setClassStatus(ClassStatus.IN_PROGRESS);
  510. showToast('success', '课堂已开始');
  511. } catch (err: any) {
  512. setErrorMessage(`开始上课失败: ${err.message}`);
  513. }
  514. };
  515. // 结束上课
  516. const endClass = async (): Promise<void> => {
  517. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  518. try {
  519. await imMessageManager.current.sendGroupMessage({
  520. groupId: classId,
  521. data: JSON.stringify({ action: 'end_class' }),
  522. type: 88889, // 自定义消息类型
  523. level: ImMessageLevel.HIGH,
  524. });
  525. setClassStatus(ClassStatus.ENDED);
  526. showToast('success', '课堂已结束');
  527. } catch (err: any) {
  528. setErrorMessage(`结束上课失败: ${err.message}`);
  529. }
  530. };
  531. // 静音/取消静音成员
  532. const toggleMuteMember = async (userId: string, mute: boolean): Promise<void> => {
  533. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  534. try {
  535. await imMessageManager.current.sendGroupMessage({
  536. groupId: classId,
  537. data: JSON.stringify({
  538. action: 'toggle_mute',
  539. userId,
  540. mute
  541. }),
  542. type: 88890, // 自定义消息类型
  543. level: ImMessageLevel.HIGH,
  544. });
  545. showToast('info', mute ? `已静音用户 ${userId}` : `已取消静音用户 ${userId}`);
  546. } catch (err: any) {
  547. setErrorMessage(`操作失败: ${err.message}`);
  548. }
  549. };
  550. // 创建课堂
  551. const createClass = async (className: string, maxMembers = 200): Promise<string | null> => {
  552. if (!imEngine.current || !isLoggedIn || role !== Role.Teacher) {
  553. showToast('error', '只有老师可以创建课堂');
  554. return null;
  555. }
  556. try {
  557. const groupManager = imEngine.current.getGroupManager();
  558. if (!groupManager) {
  559. throw new Error('群组管理器未初始化');
  560. }
  561. // 显示创建中状态
  562. showToast('info', '正在创建课堂...');
  563. // 调用IM SDK创建群组
  564. const response = await groupManager.createGroup({
  565. groupName: className,
  566. groupMeta: JSON.stringify({
  567. classType: 'interactive',
  568. creator: userId,
  569. createdAt: Date.now(),
  570. maxMembers
  571. })
  572. });
  573. if (!response?.groupId) {
  574. throw new Error('创建群组失败: 未返回群组ID');
  575. }
  576. // 创建成功后自动加入群组
  577. try {
  578. await groupManager.joinGroup(response.groupId);
  579. showToast('success', '课堂创建并加入成功');
  580. showMessage(`课堂 ${className} 创建成功,ID: ${response.groupId}`);
  581. // 更新状态
  582. setClassId(response.groupId);
  583. setIsJoinedClass(true);
  584. // 初始化群组消息管理器
  585. const messageManager = imEngine.current.getMessageManager();
  586. if (messageManager) {
  587. imMessageManager.current = messageManager;
  588. listenMessageEvents();
  589. }
  590. // 记录创建时间
  591. const createTime = new Date();
  592. showMessage(`创建时间: ${createTime.toLocaleString()}`);
  593. // 创建成功后生成分享链接
  594. setShareLink(`${window.location.href.split('?')[0]}?classId=${response.groupId}`);
  595. return response.groupId;
  596. } catch (joinErr: any) {
  597. throw new Error(`创建成功但加入失败: ${joinErr.message}`);
  598. }
  599. } catch (err: any) {
  600. const errorMsg = err.message.includes('alreadyExist')
  601. ? '课堂已存在'
  602. : `课堂创建失败: ${err.message}`;
  603. setErrorMessage(errorMsg);
  604. showToast('error', errorMsg);
  605. return null;
  606. }
  607. };
  608. // 切换摄像头状态
  609. const toggleCamera = async () => {
  610. if (!aliRtcEngine.current) return;
  611. try {
  612. if (isCameraOn) {
  613. await aliRtcEngine.current.stopPreview();
  614. } else {
  615. await aliRtcEngine.current.startPreview();
  616. }
  617. setIsCameraOn(!isCameraOn);
  618. showToast('info', `摄像头已${isCameraOn ? '关闭' : '开启'}`);
  619. } catch (err) {
  620. console.error('切换摄像头状态失败:', err);
  621. showToast('error', '切换摄像头失败');
  622. }
  623. };
  624. // 清理资源
  625. useEffect(() => {
  626. return () => {
  627. if (imGroupManager.current) {
  628. imGroupManager.current.removeAllListeners();
  629. }
  630. if (imMessageManager.current) {
  631. imMessageManager.current.removeAllListeners();
  632. }
  633. if (imEngine.current) {
  634. imEngine.current.removeAllListeners();
  635. }
  636. if (aliRtcEngine.current) {
  637. aliRtcEngine.current.destroy();
  638. }
  639. };
  640. }, []);
  641. // 学生举手
  642. const handUp = async (question?: string): Promise<void> => {
  643. if (!imMessageManager.current || !classId || role !== 'student') return;
  644. try {
  645. await imMessageManager.current.sendGroupMessage({
  646. groupId: classId,
  647. data: JSON.stringify({
  648. action: 'hand_up',
  649. studentId: userId,
  650. timestamp: Date.now(),
  651. question
  652. }),
  653. type: 88891,
  654. level: ImMessageLevel.NORMAL,
  655. });
  656. } catch (err: any) {
  657. setErrorMessage(`举手失败: ${err.message}`);
  658. }
  659. };
  660. // 老师应答举手
  661. const answerHandUp = async (studentId: string): Promise<void> => {
  662. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  663. try {
  664. await imMessageManager.current.sendGroupMessage({
  665. groupId: classId,
  666. data: JSON.stringify({
  667. action: 'answer_hand_up',
  668. studentId
  669. }),
  670. type: 88893,
  671. level: ImMessageLevel.HIGH,
  672. });
  673. } catch (err: any) {
  674. setErrorMessage(`应答失败: ${err.message}`);
  675. }
  676. };
  677. // 发送问题
  678. const sendQuestion = async (question: string): Promise<void> => {
  679. if (!imMessageManager.current || !classId) return;
  680. try {
  681. await imMessageManager.current.sendGroupMessage({
  682. groupId: classId,
  683. data: question,
  684. type: 88892,
  685. level: ImMessageLevel.NORMAL,
  686. });
  687. } catch (err: any) {
  688. setErrorMessage(`问题发送失败: ${err.message}`);
  689. }
  690. };
  691. return (
  692. <ClassroomContext.Provider value={{
  693. userId,
  694. role,
  695. isLoggedIn,
  696. isJoinedClass,
  697. messageList,
  698. errorMessage,
  699. classStatus,
  700. handUpList,
  701. questions,
  702. setRole: (role: Role) => setRole(role as Role),
  703. createClass,
  704. startClass,
  705. endClass,
  706. toggleMuteMember,
  707. handUp,
  708. answerHandUp,
  709. sendQuestion
  710. }}>
  711. <div className="container mx-auto p-4">
  712. <h1 className="text-2xl font-bold mb-4">互动课堂</h1>
  713. <ToastContainer
  714. position="top-right"
  715. autoClose={5000}
  716. hideProgressBar={false}
  717. newestOnTop={false}
  718. closeOnClick
  719. rtl={false}
  720. pauseOnFocusLoss
  721. draggable
  722. pauseOnHover
  723. />
  724. <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
  725. <div className="md:col-span-1">
  726. {shareLink && (
  727. <div className="mb-4 p-4 bg-white rounded-md shadow">
  728. <h4 className="text-lg font-medium mb-2">课堂分享链接</h4>
  729. <div className="flex items-center">
  730. <input
  731. type="text"
  732. readOnly
  733. value={shareLink}
  734. className="flex-1 px-3 py-2 border border-gray-300 rounded-l-md"
  735. />
  736. <button
  737. type="button"
  738. className="px-3 py-2 bg-blue-600 text-white rounded-r-md"
  739. onClick={() => {
  740. navigator.clipboard.writeText(shareLink);
  741. showToast('info', '链接已复制');
  742. }}
  743. >
  744. 复制
  745. </button>
  746. </div>
  747. </div>
  748. )}
  749. <form>
  750. {!isLoggedIn && (
  751. <div className="mb-2">
  752. <label className="block text-sm font-medium text-gray-700">课堂名称</label>
  753. <input
  754. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  755. value={className}
  756. onChange={(e) => setClassName(e.target.value)}
  757. placeholder="输入课堂名称"
  758. />
  759. </div>
  760. )}
  761. <div className="mb-2">
  762. <label className="block text-sm font-medium text-gray-700">用户ID</label>
  763. <input
  764. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  765. value={userId}
  766. onChange={(e) => setUserId(e.target.value)}
  767. />
  768. </div>
  769. <div className="mb-2">
  770. <label className="block text-sm font-medium text-gray-700">课堂ID</label>
  771. <input
  772. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  773. value={classId}
  774. onChange={(e) => setClassId(e.target.value)}
  775. />
  776. </div>
  777. <div className="mb-2">
  778. <label className="block text-sm font-medium text-gray-700">角色</label>
  779. <select
  780. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  781. value={role}
  782. onChange={(e) => setRole(e.target.value as Role)}
  783. >
  784. <option value={Role.Student}>学生</option>
  785. <option value={Role.Teacher}>老师</option>
  786. </select>
  787. </div>
  788. <div className="flex space-x-2 mb-2">
  789. {!isLoggedIn && (
  790. <button
  791. type="button"
  792. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  793. onClick={() => login(userId)}
  794. >
  795. 登录
  796. </button>
  797. )}
  798. {isLoggedIn && role === Role.Teacher && (
  799. <button
  800. type="button"
  801. className="px-3 py-2 bg-green-600 text-white rounded-md"
  802. disabled={!className}
  803. onClick={async () => {
  804. const classId = await createClass(className);
  805. if (classId) {
  806. setClassId(classId);
  807. }
  808. }}
  809. >
  810. 创建课堂
  811. </button>
  812. )}
  813. <button
  814. type="button"
  815. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  816. disabled={!isLoggedIn || isJoinedClass}
  817. onClick={() => joinClass(classId)}
  818. >
  819. 加入课堂
  820. </button>
  821. <button
  822. type="button"
  823. className="px-3 py-2 bg-gray-600 text-white rounded-md"
  824. disabled={!isJoinedClass}
  825. onClick={leaveClass}
  826. >
  827. 离开课堂
  828. </button>
  829. </div>
  830. </form>
  831. <div className="mt-4">
  832. <label className="block text-sm font-medium text-gray-700">消息</label>
  833. <input
  834. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  835. value={msgText}
  836. onChange={(e) => setMsgText(e.target.value)}
  837. />
  838. <button
  839. type="button"
  840. className="mt-2 px-3 py-2 bg-blue-600 text-white rounded-md"
  841. disabled={!isJoinedClass}
  842. onClick={sendMessage}
  843. >
  844. 发送
  845. </button>
  846. </div>
  847. {role === 'student' && isJoinedClass && (
  848. <div className="mt-4 p-4 bg-white rounded-md shadow">
  849. <h4 className="text-lg font-medium mb-2">互动功能</h4>
  850. <div className="space-y-3">
  851. <button
  852. type="button"
  853. className="w-full px-3 py-2 bg-green-600 text-white rounded-md"
  854. onClick={() => handUp()}
  855. >
  856. 举手
  857. </button>
  858. <div className="flex space-x-2">
  859. <input
  860. type="text"
  861. placeholder="输入问题..."
  862. className="flex-1 px-3 py-2 border border-gray-300 rounded-md"
  863. id="questionInput"
  864. />
  865. <button
  866. type="button"
  867. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  868. onClick={() => {
  869. const input = document.getElementById('questionInput') as HTMLInputElement;
  870. if (input.value) {
  871. sendQuestion(input.value);
  872. input.value = '';
  873. }
  874. }}
  875. >
  876. 提问
  877. </button>
  878. </div>
  879. </div>
  880. </div>
  881. )}
  882. {role === Role.Teacher && handUpList.length > 0 && (
  883. <div className="mt-4 p-4 bg-white rounded-md shadow">
  884. <h4 className="text-lg font-medium mb-2">举手列表 ({handUpList.length})</h4>
  885. <div className="space-y-2">
  886. {handUpList.map((req, i) => (
  887. <div key={i} className="flex items-center justify-between p-2 border-b">
  888. <div>
  889. <div className="font-medium">{req.studentName || req.studentId}</div>
  890. {req.question && <div className="text-sm text-gray-500">{req.question}</div>}
  891. </div>
  892. <button
  893. type="button"
  894. className="px-2 py-1 bg-blue-600 text-white rounded text-sm"
  895. onClick={() => answerHandUp(req.studentId)}
  896. >
  897. 应答
  898. </button>
  899. </div>
  900. ))}
  901. </div>
  902. </div>
  903. )}
  904. {questions.length > 0 && (
  905. <div className="mt-4 p-4 bg-white rounded-md shadow">
  906. <h4 className="text-lg font-medium mb-2">问题列表 ({questions.length})</h4>
  907. <div className="space-y-2">
  908. {questions.map((q, i) => (
  909. <div key={i} className="p-2 border-b">
  910. <div className="font-medium">问题 {i + 1}</div>
  911. <div className="text-gray-700">{q}</div>
  912. </div>
  913. ))}
  914. </div>
  915. </div>
  916. )}
  917. </div>
  918. <div className="md:col-span-1">
  919. <h4 className="text-lg font-medium mb-2">消息记录</h4>
  920. <div className="bg-gray-100 p-2 rounded-md h-64 overflow-y-auto">
  921. {messageList.map((msg, i) => (
  922. <div key={i} className="mb-1">{msg}</div>
  923. ))}
  924. </div>
  925. {role === Role.Teacher && isJoinedClass && (
  926. <div className="mt-4 p-4 bg-white rounded-md shadow">
  927. <h4 className="text-lg font-medium mb-2">老师控制面板</h4>
  928. <div className="flex space-x-2 mb-4">
  929. <button
  930. type="button"
  931. className="px-3 py-2 bg-green-600 text-white rounded-md"
  932. disabled={classStatus === ClassStatus.IN_PROGRESS}
  933. onClick={startClass}
  934. >
  935. 开始上课
  936. </button>
  937. <button
  938. type="button"
  939. className="px-3 py-2 bg-red-600 text-white rounded-md"
  940. disabled={classStatus !== ClassStatus.IN_PROGRESS}
  941. onClick={endClass}
  942. >
  943. 结束上课
  944. </button>
  945. </div>
  946. <div>
  947. <h5 className="font-medium mb-2">成员管理</h5>
  948. <div className="space-y-2">
  949. {students.map(student => (
  950. <div key={student.id} className="flex items-center justify-between">
  951. <span>{student.name}</span>
  952. <div className="space-x-2">
  953. <button
  954. type="button"
  955. className="px-2 py-1 bg-yellow-500 text-white rounded text-sm"
  956. onClick={() => toggleMuteMember(student.id, true)}
  957. >
  958. 静音
  959. </button>
  960. <button
  961. type="button"
  962. className="px-2 py-1 bg-blue-500 text-white rounded text-sm"
  963. onClick={() => toggleMuteMember(student.id, false)}
  964. >
  965. 取消静音
  966. </button>
  967. </div>
  968. </div>
  969. ))}
  970. </div>
  971. </div>
  972. </div>
  973. )}
  974. </div>
  975. <div className="md:col-span-1">
  976. <div className="mb-4">
  977. <h4 className="text-lg font-medium mb-2">本地视频</h4>
  978. <div className="relative">
  979. <video
  980. id="localPreviewer"
  981. muted
  982. className="w-full h-48 bg-black"
  983. ></video>
  984. <button
  985. onClick={toggleCamera}
  986. className="absolute bottom-2 right-2 px-3 py-1 bg-blue-600 text-white rounded-md"
  987. >
  988. {isCameraOn ? '关闭摄像头' : '开启摄像头'}
  989. </button>
  990. </div>
  991. </div>
  992. <div>
  993. <h4 className="text-lg font-medium mb-2">远程视频</h4>
  994. <div
  995. id="remoteVideoContainer"
  996. ref={remoteVideoContainer}
  997. className="grid grid-cols-2 gap-2"
  998. ></div>
  999. </div>
  1000. </div>
  1001. </div>
  1002. {errorMessage && (
  1003. <div className="mt-2 text-red-500">{errorMessage}</div>
  1004. )}
  1005. </div>
  1006. </ClassroomContext.Provider>
  1007. );
  1008. };