pages_classroom.tsx 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111
  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>(false);
  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. // 加入RTC频道
  566. const joinRtcChannel = async () => {
  567. if (!aliRtcEngine.current) return;
  568. const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
  569. const token = await generateToken(RTC_APP_ID, RTC_APP_KEY, classId, userId, timestamp);
  570. await aliRtcEngine.current.joinChannel(
  571. {
  572. channelId: classId,
  573. userId,
  574. appId: RTC_APP_ID,
  575. token,
  576. timestamp,
  577. },
  578. userId
  579. );
  580. showToast('info', '已加入RTC频道');
  581. };
  582. // 离开RTC频道
  583. const leaveRtcChannel = async () => {
  584. if (!aliRtcEngine.current) return;
  585. await aliRtcEngine.current.leaveChannel();
  586. showToast('info', '已离开RTC频道');
  587. };
  588. // 开启摄像头预览
  589. const startCameraPreview = async () => {
  590. if (!aliRtcEngine.current) return;
  591. aliRtcEngine.current.setLocalViewConfig('localPreviewer', AliRtcVideoTrack.AliRtcVideoTrackCamera);
  592. await aliRtcEngine.current.startPreview();
  593. showToast('info', '摄像头已开启');
  594. };
  595. // 关闭摄像头预览
  596. const stopCameraPreview = async () => {
  597. if (!aliRtcEngine.current) return;
  598. await aliRtcEngine.current.stopPreview();
  599. showToast('info', '摄像头已关闭');
  600. };
  601. // 切换摄像头状态
  602. const toggleCamera = async () => {
  603. try {
  604. if (isCameraOn) {
  605. await stopCameraPreview();
  606. } else {
  607. await startCameraPreview();
  608. }
  609. setIsCameraOn(!isCameraOn);
  610. } catch (err) {
  611. console.error('切换摄像头状态失败:', err);
  612. showToast('error', '切换摄像头失败');
  613. }
  614. };
  615. // 清理资源
  616. useEffect(() => {
  617. return () => {
  618. if (imGroupManager.current) {
  619. imGroupManager.current.removeAllListeners();
  620. }
  621. if (imMessageManager.current) {
  622. imMessageManager.current.removeAllListeners();
  623. }
  624. if (imEngine.current) {
  625. imEngine.current.removeAllListeners();
  626. }
  627. if (aliRtcEngine.current) {
  628. aliRtcEngine.current.destroy();
  629. }
  630. };
  631. }, []);
  632. // 学生举手
  633. const handUp = async (question?: string): Promise<void> => {
  634. if (!imMessageManager.current || !classId || role !== 'student') return;
  635. try {
  636. await imMessageManager.current.sendGroupMessage({
  637. groupId: classId,
  638. data: JSON.stringify({
  639. action: 'hand_up',
  640. studentId: userId,
  641. timestamp: Date.now(),
  642. question
  643. }),
  644. type: 88891,
  645. level: ImMessageLevel.NORMAL,
  646. });
  647. } catch (err: any) {
  648. setErrorMessage(`举手失败: ${err.message}`);
  649. }
  650. };
  651. // 老师应答举手
  652. const answerHandUp = async (studentId: string): Promise<void> => {
  653. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  654. try {
  655. await imMessageManager.current.sendGroupMessage({
  656. groupId: classId,
  657. data: JSON.stringify({
  658. action: 'answer_hand_up',
  659. studentId
  660. }),
  661. type: 88893,
  662. level: ImMessageLevel.HIGH,
  663. });
  664. } catch (err: any) {
  665. setErrorMessage(`应答失败: ${err.message}`);
  666. }
  667. };
  668. // 发送问题
  669. const sendQuestion = async (question: string): Promise<void> => {
  670. if (!imMessageManager.current || !classId) return;
  671. try {
  672. await imMessageManager.current.sendGroupMessage({
  673. groupId: classId,
  674. data: question,
  675. type: 88892,
  676. level: ImMessageLevel.NORMAL,
  677. });
  678. } catch (err: any) {
  679. setErrorMessage(`问题发送失败: ${err.message}`);
  680. }
  681. };
  682. return (
  683. <ClassroomContext.Provider value={{
  684. userId,
  685. role,
  686. isLoggedIn,
  687. isJoinedClass,
  688. messageList,
  689. errorMessage,
  690. classStatus,
  691. handUpList,
  692. questions,
  693. setRole: (role: Role) => setRole(role as Role),
  694. createClass,
  695. startClass,
  696. endClass,
  697. toggleMuteMember,
  698. handUp,
  699. answerHandUp,
  700. sendQuestion
  701. }}>
  702. <div className="container mx-auto p-4">
  703. <h1 className="text-2xl font-bold mb-4">互动课堂</h1>
  704. <ToastContainer
  705. position="top-right"
  706. autoClose={5000}
  707. hideProgressBar={false}
  708. newestOnTop={false}
  709. closeOnClick
  710. rtl={false}
  711. pauseOnFocusLoss
  712. draggable
  713. pauseOnHover
  714. />
  715. <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
  716. <div className="md:col-span-1">
  717. {shareLink && (
  718. <div className="mb-4 p-4 bg-white rounded-md shadow">
  719. <h4 className="text-lg font-medium mb-2">课堂分享链接</h4>
  720. <div className="flex items-center">
  721. <input
  722. type="text"
  723. readOnly
  724. value={shareLink}
  725. className="flex-1 px-3 py-2 border border-gray-300 rounded-l-md"
  726. />
  727. <button
  728. type="button"
  729. className="px-3 py-2 bg-blue-600 text-white rounded-r-md"
  730. onClick={() => {
  731. navigator.clipboard.writeText(shareLink);
  732. showToast('info', '链接已复制');
  733. }}
  734. >
  735. 复制
  736. </button>
  737. </div>
  738. </div>
  739. )}
  740. <form>
  741. {!isLoggedIn && (
  742. <div className="mb-2">
  743. <label className="block text-sm font-medium text-gray-700">课堂名称</label>
  744. <input
  745. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  746. value={className}
  747. onChange={(e) => setClassName(e.target.value)}
  748. placeholder="输入课堂名称"
  749. />
  750. </div>
  751. )}
  752. <div className="mb-2">
  753. <label className="block text-sm font-medium text-gray-700">用户ID</label>
  754. <input
  755. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  756. value={userId}
  757. onChange={(e) => setUserId(e.target.value)}
  758. />
  759. </div>
  760. <div className="mb-2">
  761. <label className="block text-sm font-medium text-gray-700">课堂ID</label>
  762. <input
  763. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  764. value={classId}
  765. onChange={(e) => setClassId(e.target.value)}
  766. />
  767. </div>
  768. <div className="mb-2">
  769. <label className="block text-sm font-medium text-gray-700">角色</label>
  770. <select
  771. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  772. value={role}
  773. onChange={(e) => setRole(e.target.value as Role)}
  774. >
  775. <option value={Role.Student}>学生</option>
  776. <option value={Role.Teacher}>老师</option>
  777. </select>
  778. </div>
  779. <div className="flex space-x-2 mb-2">
  780. {!isLoggedIn && (
  781. <button
  782. type="button"
  783. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  784. onClick={() => login(userId)}
  785. >
  786. 登录
  787. </button>
  788. )}
  789. {isLoggedIn && role === Role.Teacher && (
  790. <button
  791. type="button"
  792. className="px-3 py-2 bg-green-600 text-white rounded-md"
  793. disabled={!className}
  794. onClick={async () => {
  795. const classId = await createClass(className);
  796. if (classId) {
  797. setClassId(classId);
  798. }
  799. }}
  800. >
  801. 创建课堂
  802. </button>
  803. )}
  804. <button
  805. type="button"
  806. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  807. disabled={!isLoggedIn || isJoinedClass}
  808. onClick={() => joinClass(classId)}
  809. >
  810. 加入课堂
  811. </button>
  812. <button
  813. type="button"
  814. className="px-3 py-2 bg-gray-600 text-white rounded-md"
  815. disabled={!isJoinedClass}
  816. onClick={leaveClass}
  817. >
  818. 离开课堂
  819. </button>
  820. </div>
  821. </form>
  822. <div className="mt-4">
  823. <label className="block text-sm font-medium text-gray-700">消息</label>
  824. <input
  825. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  826. value={msgText}
  827. onChange={(e) => setMsgText(e.target.value)}
  828. />
  829. <button
  830. type="button"
  831. className="mt-2 px-3 py-2 bg-blue-600 text-white rounded-md"
  832. disabled={!isJoinedClass}
  833. onClick={sendMessage}
  834. >
  835. 发送
  836. </button>
  837. </div>
  838. {role === 'student' && isJoinedClass && (
  839. <div className="mt-4 p-4 bg-white rounded-md shadow">
  840. <h4 className="text-lg font-medium mb-2">互动功能</h4>
  841. <div className="space-y-3">
  842. <button
  843. type="button"
  844. className="w-full px-3 py-2 bg-green-600 text-white rounded-md"
  845. onClick={() => handUp()}
  846. >
  847. 举手
  848. </button>
  849. <div className="flex space-x-2">
  850. <input
  851. type="text"
  852. placeholder="输入问题..."
  853. className="flex-1 px-3 py-2 border border-gray-300 rounded-md"
  854. id="questionInput"
  855. />
  856. <button
  857. type="button"
  858. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  859. onClick={() => {
  860. const input = document.getElementById('questionInput') as HTMLInputElement;
  861. if (input.value) {
  862. sendQuestion(input.value);
  863. input.value = '';
  864. }
  865. }}
  866. >
  867. 提问
  868. </button>
  869. </div>
  870. </div>
  871. </div>
  872. )}
  873. {role === Role.Teacher && handUpList.length > 0 && (
  874. <div className="mt-4 p-4 bg-white rounded-md shadow">
  875. <h4 className="text-lg font-medium mb-2">举手列表 ({handUpList.length})</h4>
  876. <div className="space-y-2">
  877. {handUpList.map((req, i) => (
  878. <div key={i} className="flex items-center justify-between p-2 border-b">
  879. <div>
  880. <div className="font-medium">{req.studentName || req.studentId}</div>
  881. {req.question && <div className="text-sm text-gray-500">{req.question}</div>}
  882. </div>
  883. <button
  884. type="button"
  885. className="px-2 py-1 bg-blue-600 text-white rounded text-sm"
  886. onClick={() => answerHandUp(req.studentId)}
  887. >
  888. 应答
  889. </button>
  890. </div>
  891. ))}
  892. </div>
  893. </div>
  894. )}
  895. {questions.length > 0 && (
  896. <div className="mt-4 p-4 bg-white rounded-md shadow">
  897. <h4 className="text-lg font-medium mb-2">问题列表 ({questions.length})</h4>
  898. <div className="space-y-2">
  899. {questions.map((q, i) => (
  900. <div key={i} className="p-2 border-b">
  901. <div className="font-medium">问题 {i + 1}</div>
  902. <div className="text-gray-700">{q}</div>
  903. </div>
  904. ))}
  905. </div>
  906. </div>
  907. )}
  908. </div>
  909. <div className="md:col-span-1">
  910. <h4 className="text-lg font-medium mb-2">消息记录</h4>
  911. <div className="bg-gray-100 p-2 rounded-md h-64 overflow-y-auto">
  912. {messageList.map((msg, i) => (
  913. <div key={i} className="mb-1">{msg}</div>
  914. ))}
  915. </div>
  916. {role === Role.Teacher && isJoinedClass && (
  917. <div className="mt-4 p-4 bg-white rounded-md shadow">
  918. <h4 className="text-lg font-medium mb-2">老师控制面板</h4>
  919. <div className="flex space-x-2 mb-4">
  920. <button
  921. type="button"
  922. className="px-3 py-2 bg-green-600 text-white rounded-md"
  923. disabled={classStatus === ClassStatus.IN_PROGRESS}
  924. onClick={startClass}
  925. >
  926. 开始上课
  927. </button>
  928. <button
  929. type="button"
  930. className="px-3 py-2 bg-red-600 text-white rounded-md"
  931. disabled={classStatus !== ClassStatus.IN_PROGRESS}
  932. onClick={endClass}
  933. >
  934. 结束上课
  935. </button>
  936. </div>
  937. <div>
  938. <h5 className="font-medium mb-2">成员管理</h5>
  939. <div className="space-y-2">
  940. {students.map(student => (
  941. <div key={student.id} className="flex items-center justify-between">
  942. <span>{student.name}</span>
  943. <div className="space-x-2">
  944. <button
  945. type="button"
  946. className="px-2 py-1 bg-yellow-500 text-white rounded text-sm"
  947. onClick={() => toggleMuteMember(student.id, true)}
  948. >
  949. 静音
  950. </button>
  951. <button
  952. type="button"
  953. className="px-2 py-1 bg-blue-500 text-white rounded text-sm"
  954. onClick={() => toggleMuteMember(student.id, false)}
  955. >
  956. 取消静音
  957. </button>
  958. </div>
  959. </div>
  960. ))}
  961. </div>
  962. </div>
  963. </div>
  964. )}
  965. </div>
  966. <div className="md:col-span-1">
  967. <div className="mb-4">
  968. <h4 className="text-lg font-medium mb-2">本地视频</h4>
  969. <div className="relative">
  970. <video
  971. id="localPreviewer"
  972. muted
  973. className="w-full h-48 bg-black"
  974. ></video>
  975. <button
  976. onClick={toggleCamera}
  977. className="absolute bottom-2 right-2 px-3 py-1 bg-blue-600 text-white rounded-md"
  978. >
  979. {isCameraOn ? '关闭摄像头' : '开启摄像头'}
  980. </button>
  981. </div>
  982. </div>
  983. <div>
  984. <h4 className="text-lg font-medium mb-2">远程视频</h4>
  985. <div
  986. id="remoteVideoContainer"
  987. ref={remoteVideoContainer}
  988. className="grid grid-cols-2 gap-2"
  989. ></div>
  990. </div>
  991. </div>
  992. </div>
  993. {errorMessage && (
  994. <div className="mt-2 text-red-500">{errorMessage}</div>
  995. )}
  996. </div>
  997. </ClassroomContext.Provider>
  998. );
  999. };