pages_classroom.tsx 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128
  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. // 加入RTC频道
  421. await joinRtcChannel();
  422. setIsJoinedClass(true);
  423. setErrorMessage('');
  424. showToast('success', '加入课堂成功');
  425. } catch (err: any) {
  426. setErrorMessage(`加入课堂失败: ${err.message}`);
  427. showToast('error', '加入课堂失败');
  428. // 如果IM加入成功但RTC加入失败,需要离开IM群组
  429. if (imGroupManager.current) {
  430. try {
  431. await imGroupManager.current.leaveGroup(classId);
  432. } catch (leaveErr) {
  433. console.error('离开IM群组失败:', leaveErr);
  434. }
  435. }
  436. }
  437. };
  438. // 离开课堂
  439. const leaveClass = async (): Promise<void> => {
  440. try {
  441. if (imGroupManager.current && classId) {
  442. await imGroupManager.current.leaveGroup(classId);
  443. }
  444. if (aliRtcEngine.current) {
  445. await leaveRtcChannel();
  446. }
  447. setIsJoinedClass(false);
  448. showToast('info', '已离开课堂');
  449. } catch (err) {
  450. console.error('离开课堂失败:', err);
  451. showToast('error', '离开课堂时发生错误');
  452. }
  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. // 发送开始上课消息
  475. await imMessageManager.current.sendGroupMessage({
  476. groupId: classId,
  477. data: JSON.stringify({ action: 'start_class' }),
  478. type: 88889, // 自定义消息类型
  479. level: ImMessageLevel.HIGH,
  480. });
  481. setClassStatus(ClassStatus.IN_PROGRESS);
  482. showToast('success', '课堂已开始');
  483. } catch (err: any) {
  484. setErrorMessage(`开始上课失败: ${err.message}`);
  485. }
  486. };
  487. // 结束上课
  488. const endClass = async (): Promise<void> => {
  489. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  490. try {
  491. await imMessageManager.current.sendGroupMessage({
  492. groupId: classId,
  493. data: JSON.stringify({ action: 'end_class' }),
  494. type: 88889, // 自定义消息类型
  495. level: ImMessageLevel.HIGH,
  496. });
  497. setClassStatus(ClassStatus.ENDED);
  498. showToast('success', '课堂已结束');
  499. } catch (err: any) {
  500. setErrorMessage(`结束上课失败: ${err.message}`);
  501. }
  502. };
  503. // 静音/取消静音成员
  504. const toggleMuteMember = async (userId: string, mute: boolean): Promise<void> => {
  505. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  506. try {
  507. await imMessageManager.current.sendGroupMessage({
  508. groupId: classId,
  509. data: JSON.stringify({
  510. action: 'toggle_mute',
  511. userId,
  512. mute
  513. }),
  514. type: 88890, // 自定义消息类型
  515. level: ImMessageLevel.HIGH,
  516. });
  517. showToast('info', mute ? `已静音用户 ${userId}` : `已取消静音用户 ${userId}`);
  518. } catch (err: any) {
  519. setErrorMessage(`操作失败: ${err.message}`);
  520. }
  521. };
  522. // 创建课堂
  523. const createClass = async (className: string, maxMembers = 200): Promise<string | null> => {
  524. if (!imEngine.current || !isLoggedIn || role !== Role.Teacher) {
  525. showToast('error', '只有老师可以创建课堂');
  526. return null;
  527. }
  528. try {
  529. const groupManager = imEngine.current.getGroupManager();
  530. if (!groupManager) {
  531. throw new Error('群组管理器未初始化');
  532. }
  533. // 显示创建中状态
  534. showToast('info', '正在创建课堂...');
  535. // 调用IM SDK创建群组
  536. const response = await groupManager.createGroup({
  537. groupName: className,
  538. groupMeta: JSON.stringify({
  539. classType: 'interactive',
  540. creator: userId,
  541. createdAt: Date.now(),
  542. maxMembers
  543. })
  544. });
  545. if (!response?.groupId) {
  546. throw new Error('创建群组失败: 未返回群组ID');
  547. }
  548. // 创建成功后自动加入群组
  549. try {
  550. await groupManager.joinGroup(response.groupId);
  551. showToast('success', '课堂创建并加入成功');
  552. showMessage(`课堂 ${className} 创建成功,ID: ${response.groupId}`);
  553. // 更新状态
  554. setClassId(response.groupId);
  555. setIsJoinedClass(true);
  556. // 初始化群组消息管理器
  557. const messageManager = imEngine.current.getMessageManager();
  558. if (messageManager) {
  559. imMessageManager.current = messageManager;
  560. listenMessageEvents();
  561. }
  562. // 记录创建时间
  563. const createTime = new Date();
  564. showMessage(`创建时间: ${createTime.toLocaleString()}`);
  565. // 创建成功后生成分享链接
  566. setShareLink(`${window.location.href.split('?')[0]}?classId=${response.groupId}`);
  567. return response.groupId;
  568. } catch (joinErr: any) {
  569. throw new Error(`创建成功但加入失败: ${joinErr.message}`);
  570. }
  571. } catch (err: any) {
  572. const errorMsg = err.message.includes('alreadyExist')
  573. ? '课堂已存在'
  574. : `课堂创建失败: ${err.message}`;
  575. setErrorMessage(errorMsg);
  576. showToast('error', errorMsg);
  577. return null;
  578. }
  579. };
  580. // 加入RTC频道
  581. const joinRtcChannel = async () => {
  582. if (!aliRtcEngine.current) return;
  583. const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
  584. const token = await generateToken(RTC_APP_ID, RTC_APP_KEY, classId, userId, timestamp);
  585. await aliRtcEngine.current.joinChannel(
  586. {
  587. channelId: classId,
  588. userId,
  589. appId: RTC_APP_ID,
  590. token,
  591. timestamp,
  592. },
  593. userId
  594. );
  595. showToast('info', '已加入RTC频道');
  596. };
  597. // 离开RTC频道
  598. const leaveRtcChannel = async () => {
  599. if (!aliRtcEngine.current) return;
  600. await aliRtcEngine.current.leaveChannel();
  601. showToast('info', '已离开RTC频道');
  602. };
  603. // 开启摄像头预览
  604. const startCameraPreview = async () => {
  605. if (!aliRtcEngine.current) return;
  606. aliRtcEngine.current.setLocalViewConfig('localPreviewer', AliRtcVideoTrack.AliRtcVideoTrackCamera);
  607. await aliRtcEngine.current.startPreview();
  608. showToast('info', '摄像头已开启');
  609. };
  610. // 关闭摄像头预览
  611. const stopCameraPreview = async () => {
  612. if (!aliRtcEngine.current) return;
  613. await aliRtcEngine.current.stopPreview();
  614. showToast('info', '摄像头已关闭');
  615. };
  616. // 切换摄像头状态
  617. const toggleCamera = async () => {
  618. try {
  619. if (isCameraOn) {
  620. await stopCameraPreview();
  621. } else {
  622. await startCameraPreview();
  623. }
  624. setIsCameraOn(!isCameraOn);
  625. } catch (err) {
  626. console.error('切换摄像头状态失败:', err);
  627. showToast('error', '切换摄像头失败');
  628. }
  629. };
  630. // 清理资源
  631. useEffect(() => {
  632. return () => {
  633. if (imGroupManager.current) {
  634. imGroupManager.current.removeAllListeners();
  635. }
  636. if (imMessageManager.current) {
  637. imMessageManager.current.removeAllListeners();
  638. }
  639. if (imEngine.current) {
  640. imEngine.current.removeAllListeners();
  641. }
  642. if (aliRtcEngine.current) {
  643. aliRtcEngine.current.destroy();
  644. }
  645. };
  646. }, []);
  647. // 学生举手
  648. const handUp = async (question?: string): Promise<void> => {
  649. if (!imMessageManager.current || !classId || role !== 'student') return;
  650. try {
  651. await imMessageManager.current.sendGroupMessage({
  652. groupId: classId,
  653. data: JSON.stringify({
  654. action: 'hand_up',
  655. studentId: userId,
  656. timestamp: Date.now(),
  657. question
  658. }),
  659. type: 88891,
  660. level: ImMessageLevel.NORMAL,
  661. });
  662. } catch (err: any) {
  663. setErrorMessage(`举手失败: ${err.message}`);
  664. }
  665. };
  666. // 老师应答举手
  667. const answerHandUp = async (studentId: string): Promise<void> => {
  668. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  669. try {
  670. await imMessageManager.current.sendGroupMessage({
  671. groupId: classId,
  672. data: JSON.stringify({
  673. action: 'answer_hand_up',
  674. studentId
  675. }),
  676. type: 88893,
  677. level: ImMessageLevel.HIGH,
  678. });
  679. } catch (err: any) {
  680. setErrorMessage(`应答失败: ${err.message}`);
  681. }
  682. };
  683. // 发送问题
  684. const sendQuestion = async (question: string): Promise<void> => {
  685. if (!imMessageManager.current || !classId) return;
  686. try {
  687. await imMessageManager.current.sendGroupMessage({
  688. groupId: classId,
  689. data: question,
  690. type: 88892,
  691. level: ImMessageLevel.NORMAL,
  692. });
  693. } catch (err: any) {
  694. setErrorMessage(`问题发送失败: ${err.message}`);
  695. }
  696. };
  697. return (
  698. <ClassroomContext.Provider value={{
  699. userId,
  700. role,
  701. isLoggedIn,
  702. isJoinedClass,
  703. messageList,
  704. errorMessage,
  705. classStatus,
  706. handUpList,
  707. questions,
  708. setRole: (role: Role) => setRole(role as Role),
  709. createClass,
  710. startClass,
  711. endClass,
  712. toggleMuteMember,
  713. handUp,
  714. answerHandUp,
  715. sendQuestion
  716. }}>
  717. <div className="container mx-auto p-4">
  718. <h1 className="text-2xl font-bold mb-4">互动课堂</h1>
  719. <ToastContainer
  720. position="top-right"
  721. autoClose={5000}
  722. hideProgressBar={false}
  723. newestOnTop={false}
  724. closeOnClick
  725. rtl={false}
  726. pauseOnFocusLoss
  727. draggable
  728. pauseOnHover
  729. />
  730. <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
  731. <div className="md:col-span-1">
  732. {shareLink && (
  733. <div className="mb-4 p-4 bg-white rounded-md shadow">
  734. <h4 className="text-lg font-medium mb-2">课堂分享链接</h4>
  735. <div className="flex items-center">
  736. <input
  737. type="text"
  738. readOnly
  739. value={shareLink}
  740. className="flex-1 px-3 py-2 border border-gray-300 rounded-l-md"
  741. />
  742. <button
  743. type="button"
  744. className="px-3 py-2 bg-blue-600 text-white rounded-r-md"
  745. onClick={() => {
  746. navigator.clipboard.writeText(shareLink);
  747. showToast('info', '链接已复制');
  748. }}
  749. >
  750. 复制
  751. </button>
  752. </div>
  753. </div>
  754. )}
  755. <form>
  756. {!isLoggedIn && (
  757. <div className="mb-2">
  758. <label className="block text-sm font-medium text-gray-700">课堂名称</label>
  759. <input
  760. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  761. value={className}
  762. onChange={(e) => setClassName(e.target.value)}
  763. placeholder="输入课堂名称"
  764. />
  765. </div>
  766. )}
  767. <div className="mb-2">
  768. <label className="block text-sm font-medium text-gray-700">用户ID</label>
  769. <input
  770. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  771. value={userId}
  772. onChange={(e) => setUserId(e.target.value)}
  773. />
  774. </div>
  775. <div className="mb-2">
  776. <label className="block text-sm font-medium text-gray-700">课堂ID</label>
  777. <input
  778. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  779. value={classId}
  780. onChange={(e) => setClassId(e.target.value)}
  781. />
  782. </div>
  783. <div className="mb-2">
  784. <label className="block text-sm font-medium text-gray-700">角色</label>
  785. <select
  786. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  787. value={role}
  788. onChange={(e) => setRole(e.target.value as Role)}
  789. >
  790. <option value={Role.Student}>学生</option>
  791. <option value={Role.Teacher}>老师</option>
  792. </select>
  793. </div>
  794. <div className="flex space-x-2 mb-2">
  795. {!isLoggedIn && (
  796. <button
  797. type="button"
  798. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  799. onClick={() => login(userId)}
  800. >
  801. 登录
  802. </button>
  803. )}
  804. {isLoggedIn && role === Role.Teacher && (
  805. <button
  806. type="button"
  807. className="px-3 py-2 bg-green-600 text-white rounded-md"
  808. disabled={!className}
  809. onClick={async () => {
  810. const classId = await createClass(className);
  811. if (classId) {
  812. setClassId(classId);
  813. }
  814. }}
  815. >
  816. 创建课堂
  817. </button>
  818. )}
  819. <button
  820. type="button"
  821. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  822. disabled={!isLoggedIn || isJoinedClass}
  823. onClick={() => joinClass(classId)}
  824. >
  825. 加入课堂
  826. </button>
  827. <button
  828. type="button"
  829. className="px-3 py-2 bg-gray-600 text-white rounded-md"
  830. disabled={!isJoinedClass}
  831. onClick={leaveClass}
  832. >
  833. 离开课堂
  834. </button>
  835. </div>
  836. </form>
  837. <div className="mt-4">
  838. <label className="block text-sm font-medium text-gray-700">消息</label>
  839. <input
  840. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  841. value={msgText}
  842. onChange={(e) => setMsgText(e.target.value)}
  843. />
  844. <button
  845. type="button"
  846. className="mt-2 px-3 py-2 bg-blue-600 text-white rounded-md"
  847. disabled={!isJoinedClass}
  848. onClick={sendMessage}
  849. >
  850. 发送
  851. </button>
  852. </div>
  853. {role === 'student' && isJoinedClass && (
  854. <div className="mt-4 p-4 bg-white rounded-md shadow">
  855. <h4 className="text-lg font-medium mb-2">互动功能</h4>
  856. <div className="space-y-3">
  857. <button
  858. type="button"
  859. className="w-full px-3 py-2 bg-green-600 text-white rounded-md"
  860. onClick={() => handUp()}
  861. >
  862. 举手
  863. </button>
  864. <div className="flex space-x-2">
  865. <input
  866. type="text"
  867. placeholder="输入问题..."
  868. className="flex-1 px-3 py-2 border border-gray-300 rounded-md"
  869. id="questionInput"
  870. />
  871. <button
  872. type="button"
  873. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  874. onClick={() => {
  875. const input = document.getElementById('questionInput') as HTMLInputElement;
  876. if (input.value) {
  877. sendQuestion(input.value);
  878. input.value = '';
  879. }
  880. }}
  881. >
  882. 提问
  883. </button>
  884. </div>
  885. </div>
  886. </div>
  887. )}
  888. {role === Role.Teacher && handUpList.length > 0 && (
  889. <div className="mt-4 p-4 bg-white rounded-md shadow">
  890. <h4 className="text-lg font-medium mb-2">举手列表 ({handUpList.length})</h4>
  891. <div className="space-y-2">
  892. {handUpList.map((req, i) => (
  893. <div key={i} className="flex items-center justify-between p-2 border-b">
  894. <div>
  895. <div className="font-medium">{req.studentName || req.studentId}</div>
  896. {req.question && <div className="text-sm text-gray-500">{req.question}</div>}
  897. </div>
  898. <button
  899. type="button"
  900. className="px-2 py-1 bg-blue-600 text-white rounded text-sm"
  901. onClick={() => answerHandUp(req.studentId)}
  902. >
  903. 应答
  904. </button>
  905. </div>
  906. ))}
  907. </div>
  908. </div>
  909. )}
  910. {questions.length > 0 && (
  911. <div className="mt-4 p-4 bg-white rounded-md shadow">
  912. <h4 className="text-lg font-medium mb-2">问题列表 ({questions.length})</h4>
  913. <div className="space-y-2">
  914. {questions.map((q, i) => (
  915. <div key={i} className="p-2 border-b">
  916. <div className="font-medium">问题 {i + 1}</div>
  917. <div className="text-gray-700">{q}</div>
  918. </div>
  919. ))}
  920. </div>
  921. </div>
  922. )}
  923. </div>
  924. <div className="md:col-span-1">
  925. <h4 className="text-lg font-medium mb-2">消息记录</h4>
  926. <div className="bg-gray-100 p-2 rounded-md h-64 overflow-y-auto">
  927. {messageList.map((msg, i) => (
  928. <div key={i} className="mb-1">{msg}</div>
  929. ))}
  930. </div>
  931. {role === Role.Teacher && isJoinedClass && (
  932. <div className="mt-4 p-4 bg-white rounded-md shadow">
  933. <h4 className="text-lg font-medium mb-2">老师控制面板</h4>
  934. <div className="flex space-x-2 mb-4">
  935. <button
  936. type="button"
  937. className="px-3 py-2 bg-green-600 text-white rounded-md"
  938. disabled={classStatus === ClassStatus.IN_PROGRESS}
  939. onClick={startClass}
  940. >
  941. 开始上课
  942. </button>
  943. <button
  944. type="button"
  945. className="px-3 py-2 bg-red-600 text-white rounded-md"
  946. disabled={classStatus !== ClassStatus.IN_PROGRESS}
  947. onClick={endClass}
  948. >
  949. 结束上课
  950. </button>
  951. </div>
  952. <div>
  953. <h5 className="font-medium mb-2">成员管理</h5>
  954. <div className="space-y-2">
  955. {students.map(student => (
  956. <div key={student.id} className="flex items-center justify-between">
  957. <span>{student.name}</span>
  958. <div className="space-x-2">
  959. <button
  960. type="button"
  961. className="px-2 py-1 bg-yellow-500 text-white rounded text-sm"
  962. onClick={() => toggleMuteMember(student.id, true)}
  963. >
  964. 静音
  965. </button>
  966. <button
  967. type="button"
  968. className="px-2 py-1 bg-blue-500 text-white rounded text-sm"
  969. onClick={() => toggleMuteMember(student.id, false)}
  970. >
  971. 取消静音
  972. </button>
  973. </div>
  974. </div>
  975. ))}
  976. </div>
  977. </div>
  978. </div>
  979. )}
  980. </div>
  981. <div className="md:col-span-1">
  982. <div className="mb-4">
  983. <h4 className="text-lg font-medium mb-2">本地视频</h4>
  984. <div className="relative">
  985. <video
  986. id="localPreviewer"
  987. muted
  988. className="w-full h-48 bg-black"
  989. ></video>
  990. <button
  991. onClick={toggleCamera}
  992. className="absolute bottom-2 right-2 px-3 py-1 bg-blue-600 text-white rounded-md"
  993. >
  994. {isCameraOn ? '关闭摄像头' : '开启摄像头'}
  995. </button>
  996. </div>
  997. </div>
  998. <div>
  999. <h4 className="text-lg font-medium mb-2">远程视频</h4>
  1000. <div
  1001. id="remoteVideoContainer"
  1002. ref={remoteVideoContainer}
  1003. className="grid grid-cols-2 gap-2"
  1004. ></div>
  1005. </div>
  1006. </div>
  1007. </div>
  1008. {errorMessage && (
  1009. <div className="mt-2 text-red-500">{errorMessage}</div>
  1010. )}
  1011. </div>
  1012. </ClassroomContext.Provider>
  1013. );
  1014. };