pages_classroom.tsx 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091
  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. } else if (msg.type === 88888) { // 普通文本消息
  254. showMessage(`${msg.sender?.userId || '未知用户'}: ${msg.data}`);
  255. }
  256. });
  257. };
  258. // 音视频模块
  259. const removeRemoteVideo = (userId: string, type: 'camera' | 'screen' = 'camera') => {
  260. const vid = `${type}_${userId}`;
  261. const el = remoteVideoElMap.current[vid];
  262. if (el) {
  263. aliRtcEngine.current!.setRemoteViewConfig(null, userId, type === 'camera' ? AliRtcVideoTrack.AliRtcVideoTrackCamera : AliRtcVideoTrack.AliRtcVideoTrackScreen);
  264. el.pause();
  265. remoteVideoContainer.current?.removeChild(el);
  266. delete remoteVideoElMap.current[vid];
  267. }
  268. };
  269. const listenRtcEvents = () => {
  270. if (!aliRtcEngine.current) return;
  271. showMessage('注册rtc事件监听')
  272. aliRtcEngine.current.on('remoteUserOnLineNotify', (userId: string) => {
  273. showMessage(`用户 ${userId} 加入课堂`);
  274. console.log('用户上线通知:', userId);
  275. });
  276. aliRtcEngine.current.on('remoteUserOffLineNotify', (userId: string) => {
  277. showMessage(`用户 ${userId} 离开课堂`);
  278. console.log('用户下线通知:', userId);
  279. removeRemoteVideo(userId, 'camera');
  280. removeRemoteVideo(userId, 'screen');
  281. });
  282. // 订阅所有用户视频流
  283. aliRtcEngine.current.on('videoSubscribeStateChanged', (
  284. userId: string,
  285. oldState: AliRtcSubscribeState,
  286. newState: AliRtcSubscribeState,
  287. interval: number,
  288. channelId: string
  289. ) => {
  290. console.log(`视频订阅状态变化: 用户 ${userId}, 旧状态 ${oldState}, 新状态 ${newState}`);
  291. switch(newState) {
  292. case 3: // 订阅成功
  293. try {
  294. console.log('开始创建远程视频元素');
  295. // 检查是否已有该用户的视频元素
  296. if (remoteVideoElMap.current[`camera_${userId}`]) {
  297. console.log(`用户 ${userId} 的视频元素已存在`);
  298. return;
  299. }
  300. const video = document.createElement('video');
  301. video.autoplay = true;
  302. video.playsInline = true;
  303. video.className = 'w-80 h-45 mr-2 mb-2 bg-black';
  304. if (!remoteVideoContainer.current) {
  305. console.error('远程视频容器未找到');
  306. return;
  307. }
  308. // 确保容器可见
  309. remoteVideoContainer.current.style.display = 'block';
  310. remoteVideoContainer.current.appendChild(video);
  311. remoteVideoElMap.current[`camera_${userId}`] = video;
  312. // 设置远程视图配置
  313. aliRtcEngine.current!.setRemoteViewConfig(
  314. video,
  315. userId,
  316. AliRtcVideoTrack.AliRtcVideoTrackCamera
  317. );
  318. console.log(`已订阅用户 ${userId} 的视频流`);
  319. showMessage(`已显示用户 ${userId} 的视频`);
  320. } catch (err) {
  321. console.error(`订阅用户 ${userId} 视频流失败:`, err);
  322. showMessage(`订阅用户 ${userId} 视频流失败`);
  323. }
  324. break;
  325. case 1: // 取消订阅
  326. console.log(`取消订阅用户 ${userId} 的视频流`);
  327. removeRemoteVideo(userId, 'camera');
  328. break;
  329. case 2: // 订阅中
  330. console.log(`正在订阅用户 ${userId} 的视频流...`);
  331. break;
  332. default:
  333. console.warn(`未知订阅状态: ${newState}`);
  334. }
  335. });
  336. };
  337. // 获取学生列表
  338. const fetchStudents = async (classId: string) => {
  339. try {
  340. if (!imEngine.current) {
  341. throw new Error('IM引擎未初始化');
  342. }
  343. const groupManager = imEngine.current.getGroupManager();
  344. if (!groupManager) {
  345. throw new Error('IM群组管理器未初始化');
  346. }
  347. // 使用classId作为群组ID获取成员
  348. const response = await groupManager.listRecentGroupUser(classId);
  349. // 转换IM用户数据格式
  350. const students = response.userList.map((user: ImUser) => ({
  351. id: user.userId,
  352. name: user.userExtension || `用户${user.userId}`
  353. }));
  354. setStudents(students);
  355. } catch (err) {
  356. console.error('从IM获取学生列表失败:', err);
  357. // 可选: 显示错误提示给用户
  358. // setError('获取学生列表失败,请稍后重试');
  359. }
  360. };
  361. // 统一登录逻辑
  362. const login = async (userId: string): Promise<void> => {
  363. try {
  364. // 初始化IM
  365. const { ImEngine: ImEngineClass } = window.AliVCInteraction;
  366. imEngine.current = ImEngineClass.createEngine();
  367. await imEngine.current.init({
  368. deviceId: 'xxxx',
  369. appId: IM_APP_ID,
  370. appSign: IM_APP_SIGN,
  371. logLevel: ImLogLevel.ERROR,
  372. });
  373. // 登录IM
  374. const imToken = await generateImToken(userId, role);
  375. await imEngine.current.login({
  376. user: {
  377. userId,
  378. userExtension: '{}'
  379. },
  380. userAuth: {
  381. nonce: 'AK_4',
  382. timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
  383. token: imToken,
  384. role
  385. }
  386. });
  387. // 初始化RTC
  388. aliRtcEngine.current = AliRtcEngine.getInstance();
  389. AliRtcEngine.setLogLevel(0);
  390. // 设置事件监听
  391. listenImEvents();
  392. listenRtcEvents();
  393. setIsLoggedIn(true);
  394. setErrorMessage('');
  395. showToast('success', '登录成功');
  396. // 登录成功,不生成分享链接(将在课堂创建成功后生成)
  397. } catch (err: any) {
  398. setErrorMessage(`登录失败: ${err.message}`);
  399. showToast('error', '登录失败');
  400. }
  401. };
  402. // 加入课堂
  403. const joinClass = async (classId: string): Promise<void> => {
  404. if (!imEngine.current || !aliRtcEngine.current) return;
  405. if (!classId) {
  406. setErrorMessage('课堂ID不能为空');
  407. showToast('error', '请输入有效的课堂ID');
  408. return;
  409. }
  410. try {
  411. // 加入IM群组
  412. const gm = imEngine.current.getGroupManager();
  413. const mm = imEngine.current.getMessageManager();
  414. imGroupManager.current = gm || null;
  415. imMessageManager.current = mm || null;
  416. await gm!.joinGroup(classId);
  417. listenGroupEvents();
  418. listenMessageEvents();
  419. listenRtcEvents();
  420. setIsJoinedClass(true);
  421. setErrorMessage('');
  422. showToast('success', '加入课堂成功');
  423. } catch (err: any) {
  424. setErrorMessage(`加入课堂失败: ${err.message}`);
  425. showToast('error', '加入课堂失败');
  426. }
  427. };
  428. // 离开课堂
  429. const leaveClass = async (): Promise<void> => {
  430. if (imGroupManager.current && classId) {
  431. await imGroupManager.current.leaveGroup(classId);
  432. }
  433. if (aliRtcEngine.current) {
  434. await aliRtcEngine.current.leaveChannel();
  435. }
  436. setIsJoinedClass(false);
  437. showToast('info', '已离开课堂');
  438. };
  439. // 发送消息
  440. const sendMessage = async (): Promise<void> => {
  441. if (!imMessageManager.current || !classId) return;
  442. try {
  443. await imMessageManager.current.sendGroupMessage({
  444. groupId: classId,
  445. data: msgText,
  446. type: 88888,
  447. level: ImMessageLevel.NORMAL,
  448. });
  449. setMsgText('');
  450. setErrorMessage('');
  451. } catch (err: any) {
  452. setErrorMessage(`消息发送失败: ${err.message}`);
  453. }
  454. };
  455. // 开始上课
  456. const startClass = async (): Promise<void> => {
  457. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  458. try {
  459. // 发送开始上课消息
  460. await imMessageManager.current.sendGroupMessage({
  461. groupId: classId,
  462. data: JSON.stringify({ action: 'start_class' }),
  463. type: 88889, // 自定义消息类型
  464. level: ImMessageLevel.HIGH,
  465. });
  466. setClassStatus(ClassStatus.IN_PROGRESS);
  467. showToast('success', '课堂已开始');
  468. } catch (err: any) {
  469. setErrorMessage(`开始上课失败: ${err.message}`);
  470. }
  471. };
  472. // 结束上课
  473. const endClass = async (): Promise<void> => {
  474. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  475. try {
  476. await imMessageManager.current.sendGroupMessage({
  477. groupId: classId,
  478. data: JSON.stringify({ action: 'end_class' }),
  479. type: 88889, // 自定义消息类型
  480. level: ImMessageLevel.HIGH,
  481. });
  482. setClassStatus(ClassStatus.ENDED);
  483. showToast('success', '课堂已结束');
  484. } catch (err: any) {
  485. setErrorMessage(`结束上课失败: ${err.message}`);
  486. }
  487. };
  488. // 静音/取消静音成员
  489. const toggleMuteMember = async (userId: string, mute: boolean): Promise<void> => {
  490. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  491. try {
  492. await imMessageManager.current.sendGroupMessage({
  493. groupId: classId,
  494. data: JSON.stringify({
  495. action: 'toggle_mute',
  496. userId,
  497. mute
  498. }),
  499. type: 88890, // 自定义消息类型
  500. level: ImMessageLevel.HIGH,
  501. });
  502. showToast('info', mute ? `已静音用户 ${userId}` : `已取消静音用户 ${userId}`);
  503. } catch (err: any) {
  504. setErrorMessage(`操作失败: ${err.message}`);
  505. }
  506. };
  507. // 创建课堂
  508. const createClass = async (className: string, maxMembers = 200): Promise<string | null> => {
  509. if (!imEngine.current || !isLoggedIn || role !== Role.Teacher) {
  510. showToast('error', '只有老师可以创建课堂');
  511. return null;
  512. }
  513. try {
  514. const groupManager = imEngine.current.getGroupManager();
  515. if (!groupManager) {
  516. throw new Error('群组管理器未初始化');
  517. }
  518. // 显示创建中状态
  519. showToast('info', '正在创建课堂...');
  520. // 调用IM SDK创建群组
  521. const response = await groupManager.createGroup({
  522. groupName: className,
  523. groupMeta: JSON.stringify({
  524. classType: 'interactive',
  525. creator: userId,
  526. createdAt: Date.now(),
  527. maxMembers
  528. })
  529. });
  530. if (!response?.groupId) {
  531. throw new Error('创建群组失败: 未返回群组ID');
  532. }
  533. // 创建成功后自动加入群组
  534. try {
  535. await groupManager.joinGroup(response.groupId);
  536. showToast('success', '课堂创建并加入成功');
  537. showMessage(`课堂 ${className} 创建成功,ID: ${response.groupId}`);
  538. // 更新状态
  539. setClassId(response.groupId);
  540. setIsJoinedClass(true);
  541. // 初始化群组消息管理器
  542. const messageManager = imEngine.current.getMessageManager();
  543. if (messageManager) {
  544. imMessageManager.current = messageManager;
  545. listenMessageEvents();
  546. }
  547. // 记录创建时间
  548. const createTime = new Date();
  549. showMessage(`创建时间: ${createTime.toLocaleString()}`);
  550. // 创建成功后生成分享链接
  551. setShareLink(`${window.location.href.split('?')[0]}?classId=${response.groupId}`);
  552. return response.groupId;
  553. } catch (joinErr: any) {
  554. throw new Error(`创建成功但加入失败: ${joinErr.message}`);
  555. }
  556. } catch (err: any) {
  557. const errorMsg = err.message.includes('alreadyExist')
  558. ? '课堂已存在'
  559. : `课堂创建失败: ${err.message}`;
  560. setErrorMessage(errorMsg);
  561. showToast('error', errorMsg);
  562. return null;
  563. }
  564. };
  565. // 切换摄像头状态
  566. const toggleCamera = async () => {
  567. if (!aliRtcEngine.current) return;
  568. try {
  569. if (isCameraOn) {
  570. // 关闭摄像头并退出RTC频道
  571. await aliRtcEngine.current.stopPreview();
  572. await aliRtcEngine.current.leaveChannel();
  573. showToast('info', '摄像头已关闭并退出RTC频道');
  574. } else {
  575. // 加入RTC频道并配置本地预览
  576. const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
  577. const token = await generateToken(RTC_APP_ID, RTC_APP_KEY, classId, userId, timestamp);
  578. await aliRtcEngine.current.joinChannel(
  579. {
  580. channelId: classId,
  581. userId,
  582. appId: RTC_APP_ID,
  583. token,
  584. timestamp,
  585. },
  586. userId
  587. );
  588. // 统一设置本地预览配置
  589. aliRtcEngine.current.setLocalViewConfig('localPreviewer', AliRtcVideoTrack.AliRtcVideoTrackCamera);
  590. await aliRtcEngine.current.startPreview();
  591. showToast('info', '已加入RTC频道并开启摄像头');
  592. }
  593. setIsCameraOn(!isCameraOn);
  594. } catch (err) {
  595. console.error('切换摄像头状态失败:', err);
  596. showToast('error', '切换摄像头失败');
  597. }
  598. };
  599. // 清理资源
  600. useEffect(() => {
  601. return () => {
  602. if (imGroupManager.current) {
  603. imGroupManager.current.removeAllListeners();
  604. }
  605. if (imMessageManager.current) {
  606. imMessageManager.current.removeAllListeners();
  607. }
  608. if (imEngine.current) {
  609. imEngine.current.removeAllListeners();
  610. }
  611. if (aliRtcEngine.current) {
  612. aliRtcEngine.current.destroy();
  613. }
  614. };
  615. }, []);
  616. // 学生举手
  617. const handUp = async (question?: string): Promise<void> => {
  618. if (!imMessageManager.current || !classId || role !== 'student') return;
  619. try {
  620. await imMessageManager.current.sendGroupMessage({
  621. groupId: classId,
  622. data: JSON.stringify({
  623. action: 'hand_up',
  624. studentId: userId,
  625. timestamp: Date.now(),
  626. question
  627. }),
  628. type: 88891,
  629. level: ImMessageLevel.NORMAL,
  630. });
  631. } catch (err: any) {
  632. setErrorMessage(`举手失败: ${err.message}`);
  633. }
  634. };
  635. // 老师应答举手
  636. const answerHandUp = async (studentId: string): Promise<void> => {
  637. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  638. try {
  639. await imMessageManager.current.sendGroupMessage({
  640. groupId: classId,
  641. data: JSON.stringify({
  642. action: 'answer_hand_up',
  643. studentId
  644. }),
  645. type: 88893,
  646. level: ImMessageLevel.HIGH,
  647. });
  648. } catch (err: any) {
  649. setErrorMessage(`应答失败: ${err.message}`);
  650. }
  651. };
  652. // 发送问题
  653. const sendQuestion = async (question: string): Promise<void> => {
  654. if (!imMessageManager.current || !classId) return;
  655. try {
  656. await imMessageManager.current.sendGroupMessage({
  657. groupId: classId,
  658. data: question,
  659. type: 88892,
  660. level: ImMessageLevel.NORMAL,
  661. });
  662. } catch (err: any) {
  663. setErrorMessage(`问题发送失败: ${err.message}`);
  664. }
  665. };
  666. return (
  667. <ClassroomContext.Provider value={{
  668. userId,
  669. role,
  670. isLoggedIn,
  671. isJoinedClass,
  672. messageList,
  673. errorMessage,
  674. classStatus,
  675. handUpList,
  676. questions,
  677. setRole: (role: Role) => setRole(role as Role),
  678. createClass,
  679. startClass,
  680. endClass,
  681. toggleMuteMember,
  682. handUp,
  683. answerHandUp,
  684. sendQuestion
  685. }}>
  686. <div className="container mx-auto p-4">
  687. <h1 className="text-2xl font-bold mb-4">互动课堂</h1>
  688. <ToastContainer
  689. position="top-right"
  690. autoClose={5000}
  691. hideProgressBar={false}
  692. newestOnTop={false}
  693. closeOnClick
  694. rtl={false}
  695. pauseOnFocusLoss
  696. draggable
  697. pauseOnHover
  698. />
  699. <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
  700. <div className="md:col-span-1">
  701. {shareLink && (
  702. <div className="mb-4 p-4 bg-white rounded-md shadow">
  703. <h4 className="text-lg font-medium mb-2">课堂分享链接</h4>
  704. <div className="flex items-center">
  705. <input
  706. type="text"
  707. readOnly
  708. value={shareLink}
  709. className="flex-1 px-3 py-2 border border-gray-300 rounded-l-md"
  710. />
  711. <button
  712. type="button"
  713. className="px-3 py-2 bg-blue-600 text-white rounded-r-md"
  714. onClick={() => {
  715. navigator.clipboard.writeText(shareLink);
  716. showToast('info', '链接已复制');
  717. }}
  718. >
  719. 复制
  720. </button>
  721. </div>
  722. </div>
  723. )}
  724. <form>
  725. {!isLoggedIn && (
  726. <div className="mb-2">
  727. <label className="block text-sm font-medium text-gray-700">课堂名称</label>
  728. <input
  729. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  730. value={className}
  731. onChange={(e) => setClassName(e.target.value)}
  732. placeholder="输入课堂名称"
  733. />
  734. </div>
  735. )}
  736. <div className="mb-2">
  737. <label className="block text-sm font-medium text-gray-700">用户ID</label>
  738. <input
  739. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  740. value={userId}
  741. onChange={(e) => setUserId(e.target.value)}
  742. />
  743. </div>
  744. <div className="mb-2">
  745. <label className="block text-sm font-medium text-gray-700">课堂ID</label>
  746. <input
  747. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  748. value={classId}
  749. onChange={(e) => setClassId(e.target.value)}
  750. />
  751. </div>
  752. <div className="mb-2">
  753. <label className="block text-sm font-medium text-gray-700">角色</label>
  754. <select
  755. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  756. value={role}
  757. onChange={(e) => setRole(e.target.value as Role)}
  758. >
  759. <option value={Role.Student}>学生</option>
  760. <option value={Role.Teacher}>老师</option>
  761. </select>
  762. </div>
  763. <div className="flex space-x-2 mb-2">
  764. {!isLoggedIn && (
  765. <button
  766. type="button"
  767. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  768. onClick={() => login(userId)}
  769. >
  770. 登录
  771. </button>
  772. )}
  773. {isLoggedIn && role === Role.Teacher && (
  774. <button
  775. type="button"
  776. className="px-3 py-2 bg-green-600 text-white rounded-md"
  777. disabled={!className}
  778. onClick={async () => {
  779. const classId = await createClass(className);
  780. if (classId) {
  781. setClassId(classId);
  782. }
  783. }}
  784. >
  785. 创建课堂
  786. </button>
  787. )}
  788. <button
  789. type="button"
  790. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  791. disabled={!isLoggedIn || isJoinedClass}
  792. onClick={() => joinClass(classId)}
  793. >
  794. 加入课堂
  795. </button>
  796. <button
  797. type="button"
  798. className="px-3 py-2 bg-gray-600 text-white rounded-md"
  799. disabled={!isJoinedClass}
  800. onClick={leaveClass}
  801. >
  802. 离开课堂
  803. </button>
  804. </div>
  805. </form>
  806. <div className="mt-4">
  807. <label className="block text-sm font-medium text-gray-700">消息</label>
  808. <input
  809. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  810. value={msgText}
  811. onChange={(e) => setMsgText(e.target.value)}
  812. />
  813. <button
  814. type="button"
  815. className="mt-2 px-3 py-2 bg-blue-600 text-white rounded-md"
  816. disabled={!isJoinedClass}
  817. onClick={sendMessage}
  818. >
  819. 发送
  820. </button>
  821. </div>
  822. {role === 'student' && isJoinedClass && (
  823. <div className="mt-4 p-4 bg-white rounded-md shadow">
  824. <h4 className="text-lg font-medium mb-2">互动功能</h4>
  825. <div className="space-y-3">
  826. <button
  827. type="button"
  828. className="w-full px-3 py-2 bg-green-600 text-white rounded-md"
  829. onClick={() => handUp()}
  830. >
  831. 举手
  832. </button>
  833. <div className="flex space-x-2">
  834. <input
  835. type="text"
  836. placeholder="输入问题..."
  837. className="flex-1 px-3 py-2 border border-gray-300 rounded-md"
  838. id="questionInput"
  839. />
  840. <button
  841. type="button"
  842. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  843. onClick={() => {
  844. const input = document.getElementById('questionInput') as HTMLInputElement;
  845. if (input.value) {
  846. sendQuestion(input.value);
  847. input.value = '';
  848. }
  849. }}
  850. >
  851. 提问
  852. </button>
  853. </div>
  854. </div>
  855. </div>
  856. )}
  857. {role === Role.Teacher && handUpList.length > 0 && (
  858. <div className="mt-4 p-4 bg-white rounded-md shadow">
  859. <h4 className="text-lg font-medium mb-2">举手列表 ({handUpList.length})</h4>
  860. <div className="space-y-2">
  861. {handUpList.map((req, i) => (
  862. <div key={i} className="flex items-center justify-between p-2 border-b">
  863. <div>
  864. <div className="font-medium">{req.studentName || req.studentId}</div>
  865. {req.question && <div className="text-sm text-gray-500">{req.question}</div>}
  866. </div>
  867. <button
  868. type="button"
  869. className="px-2 py-1 bg-blue-600 text-white rounded text-sm"
  870. onClick={() => answerHandUp(req.studentId)}
  871. >
  872. 应答
  873. </button>
  874. </div>
  875. ))}
  876. </div>
  877. </div>
  878. )}
  879. {questions.length > 0 && (
  880. <div className="mt-4 p-4 bg-white rounded-md shadow">
  881. <h4 className="text-lg font-medium mb-2">问题列表 ({questions.length})</h4>
  882. <div className="space-y-2">
  883. {questions.map((q, i) => (
  884. <div key={i} className="p-2 border-b">
  885. <div className="font-medium">问题 {i + 1}</div>
  886. <div className="text-gray-700">{q}</div>
  887. </div>
  888. ))}
  889. </div>
  890. </div>
  891. )}
  892. </div>
  893. <div className="md:col-span-1">
  894. <h4 className="text-lg font-medium mb-2">消息记录</h4>
  895. <div className="bg-gray-100 p-2 rounded-md h-64 overflow-y-auto">
  896. {messageList.map((msg, i) => (
  897. <div key={i} className="mb-1">{msg}</div>
  898. ))}
  899. </div>
  900. {role === Role.Teacher && isJoinedClass && (
  901. <div className="mt-4 p-4 bg-white rounded-md shadow">
  902. <h4 className="text-lg font-medium mb-2">老师控制面板</h4>
  903. <div className="flex space-x-2 mb-4">
  904. <button
  905. type="button"
  906. className="px-3 py-2 bg-green-600 text-white rounded-md"
  907. disabled={classStatus === ClassStatus.IN_PROGRESS}
  908. onClick={startClass}
  909. >
  910. 开始上课
  911. </button>
  912. <button
  913. type="button"
  914. className="px-3 py-2 bg-red-600 text-white rounded-md"
  915. disabled={classStatus !== ClassStatus.IN_PROGRESS}
  916. onClick={endClass}
  917. >
  918. 结束上课
  919. </button>
  920. </div>
  921. <div>
  922. <h5 className="font-medium mb-2">成员管理</h5>
  923. <div className="space-y-2">
  924. {students.map(student => (
  925. <div key={student.id} className="flex items-center justify-between">
  926. <span>{student.name}</span>
  927. <div className="space-x-2">
  928. <button
  929. type="button"
  930. className="px-2 py-1 bg-yellow-500 text-white rounded text-sm"
  931. onClick={() => toggleMuteMember(student.id, true)}
  932. >
  933. 静音
  934. </button>
  935. <button
  936. type="button"
  937. className="px-2 py-1 bg-blue-500 text-white rounded text-sm"
  938. onClick={() => toggleMuteMember(student.id, false)}
  939. >
  940. 取消静音
  941. </button>
  942. </div>
  943. </div>
  944. ))}
  945. </div>
  946. </div>
  947. </div>
  948. )}
  949. </div>
  950. <div className="md:col-span-1">
  951. <div className="mb-4">
  952. <h4 className="text-lg font-medium mb-2">本地视频</h4>
  953. <div className="relative">
  954. <video
  955. id="localPreviewer"
  956. muted
  957. className="w-full h-48 bg-black"
  958. ></video>
  959. <button
  960. onClick={toggleCamera}
  961. className="absolute bottom-2 right-2 px-3 py-1 bg-blue-600 text-white rounded-md"
  962. >
  963. {isCameraOn ? '关闭摄像头' : '开启摄像头'}
  964. </button>
  965. </div>
  966. </div>
  967. <div>
  968. <h4 className="text-lg font-medium mb-2">远程视频</h4>
  969. <div
  970. id="remoteVideoContainer"
  971. ref={remoteVideoContainer}
  972. className="grid grid-cols-2 gap-2"
  973. ></div>
  974. </div>
  975. </div>
  976. </div>
  977. {errorMessage && (
  978. <div className="mt-2 text-red-500">{errorMessage}</div>
  979. )}
  980. </div>
  981. </ClassroomContext.Provider>
  982. );
  983. };