2
0

pages_classroom.tsx 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296
  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 [isAudioOn, setIsAudioOn] = useState<boolean>(false);
  136. const [isScreenSharing, setIsScreenSharing] = useState<boolean>(false);
  137. const [className, setClassName] = useState<string>('');
  138. const [role, setRole] = useState<Role>(Role.Student);
  139. const [classId, setClassId] = useState<string>('');
  140. const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
  141. const [isJoinedClass, setIsJoinedClass] = useState<boolean>(false);
  142. const [msgText, setMsgText] = useState<string>('');
  143. const [messageList, setMessageList] = useState<string[]>([]);
  144. const [errorMessage, setErrorMessage] = useState<string>('');
  145. const [classStatus, setClassStatus] = useState<ClassStatus>(ClassStatus.NOT_STARTED);
  146. const [handUpList, setHandUpList] = useState<HandUpRequest[]>([]);
  147. const [questions, setQuestions] = useState<string[]>([]);
  148. const [students, setStudents] = useState<Array<{id: string, name: string}>>([]);
  149. const [shareLink, setShareLink] = useState<string>('');
  150. // SDK实例
  151. const imEngine = useRef<ImEngine | null>(null);
  152. const imGroupManager = useRef<ImGroupManager | null>(null);
  153. const imMessageManager = useRef<ImMessageManager | null>(null);
  154. const aliRtcEngine = useRef<AliRtcEngine | null>(null);
  155. const remoteVideoElMap = useRef<Record<string, HTMLVideoElement>>({});
  156. const remoteVideoContainer = useRef<HTMLDivElement>(null);
  157. // 消息管理模块
  158. const showMessage = (text: string): void => {
  159. setMessageList((prevMessageList) => [...prevMessageList, text])
  160. };
  161. const listenImEvents = (): void => {
  162. if (!imEngine.current) return;
  163. imEngine.current.on('connectsuccess', () => {
  164. showMessage('IM连接成功');
  165. });
  166. imEngine.current.on('disconnect', async (code: number) => {
  167. showMessage(`IM断开连接: ${code}`);
  168. // 自动重连
  169. try {
  170. const imToken = await generateImToken(userId, role);
  171. await imEngine.current!.login({
  172. user: {
  173. userId,
  174. userExtension: '{}'
  175. },
  176. userAuth: {
  177. nonce: 'AK_4',
  178. timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
  179. token: imToken,
  180. role
  181. }
  182. });
  183. showMessage('IM自动重连成功');
  184. } catch (err: unknown) {
  185. const error = err as Error;
  186. showMessage(`IM自动重连失败: ${error.message}`);
  187. }
  188. });
  189. };
  190. const listenGroupEvents = (): void => {
  191. if (!imGroupManager.current) return;
  192. imGroupManager.current.on('memberchange', (groupId: string, memberCount: number, joinUsers: ImUser[], leaveUsers: ImUser[]) => {
  193. showMessage(`成员变更: 加入${joinUsers.length}人, 离开${leaveUsers.length}人`);
  194. });
  195. };
  196. const listenMessageEvents = (): void => {
  197. if (!imMessageManager.current) return;
  198. imMessageManager.current.on('recvgroupmessage', (msg: AliVCInteraction.ImMessage, groupId: string) => {
  199. if (msg.type === 88889) { // 课堂状态消息
  200. try {
  201. const data = JSON.parse(msg.data);
  202. if (data.action === 'start_class') {
  203. setClassStatus(ClassStatus.IN_PROGRESS);
  204. showMessage('老师已开始上课');
  205. } else if (data.action === 'end_class') {
  206. setClassStatus(ClassStatus.ENDED);
  207. showMessage('老师已结束上课');
  208. }
  209. } catch (err) {
  210. console.error('解析课堂状态消息失败', err);
  211. }
  212. } else if (msg.type === 88890) { // 静音指令
  213. try {
  214. const data = JSON.parse(msg.data);
  215. if (data.action === 'toggle_mute' && data.userId === userId) {
  216. showMessage(data.mute ? '你已被老师静音' : '老师已取消你的静音');
  217. }
  218. } catch (err) {
  219. console.error('解析静音指令失败', err);
  220. }
  221. } else if (msg.type === 88891) { // 举手消息
  222. try {
  223. const data = JSON.parse(msg.data) as InteractionMessage;
  224. if (data.action === 'hand_up') {
  225. const handUpData: HandUpRequest = {
  226. ...data,
  227. timestamp: data.timestamp || Date.now()
  228. };
  229. setHandUpList([...handUpList, handUpData]);
  230. showMessage(`${data.studentName || data.studentId} 举手了`);
  231. } else if (data.action === 'cancel_hand_up') {
  232. setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
  233. }
  234. } catch (err) {
  235. console.error('解析举手消息失败', err);
  236. }
  237. } else if (msg.type === 88892) { // 问题消息
  238. try {
  239. const data = JSON.parse(msg.data) as {question: string};
  240. setQuestions([...questions, data.question]);
  241. showMessage(`收到问题: ${data.question}`);
  242. } catch (err) {
  243. console.error('解析问题消息失败', err);
  244. }
  245. } else if (msg.type === 88893) { // 应答消息
  246. try {
  247. const data = JSON.parse(msg.data) as InteractionMessage;
  248. if (data.action === 'answer_hand_up' && data.studentId === userId) {
  249. showMessage('老师已应答你的举手');
  250. setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
  251. }
  252. } catch (err) {
  253. console.error('解析应答消息失败', err);
  254. }
  255. } else if (msg.type === 88888) { // 普通文本消息
  256. showMessage(`${msg.sender?.userId || '未知用户'}: ${msg.data}`);
  257. }
  258. });
  259. };
  260. // 音视频模块
  261. const removeRemoteVideo = (userId: string, type: 'camera' | 'screen' = 'camera') => {
  262. const vid = `${type}_${userId}`;
  263. const el = remoteVideoElMap.current[vid];
  264. if (el) {
  265. aliRtcEngine.current!.setRemoteViewConfig(null, userId, type === 'camera' ? AliRtcVideoTrack.AliRtcVideoTrackCamera : AliRtcVideoTrack.AliRtcVideoTrackScreen);
  266. el.pause();
  267. remoteVideoContainer.current?.removeChild(el);
  268. delete remoteVideoElMap.current[vid];
  269. }
  270. };
  271. const listenRtcEvents = () => {
  272. if (!aliRtcEngine.current) return;
  273. showMessage('注册rtc事件监听')
  274. aliRtcEngine.current.on('remoteUserOnLineNotify', (userId: string) => {
  275. showMessage(`用户 ${userId} 加入课堂`);
  276. console.log('用户上线通知:', userId);
  277. });
  278. aliRtcEngine.current.on('remoteUserOffLineNotify', (userId: string) => {
  279. showMessage(`用户 ${userId} 离开课堂`);
  280. console.log('用户下线通知:', userId);
  281. removeRemoteVideo(userId, 'camera');
  282. removeRemoteVideo(userId, 'screen');
  283. });
  284. // 订阅所有用户视频流
  285. aliRtcEngine.current.on('videoSubscribeStateChanged', (
  286. userId: string,
  287. oldState: AliRtcSubscribeState,
  288. newState: AliRtcSubscribeState,
  289. interval: number,
  290. channelId: string
  291. ) => {
  292. console.log(`视频订阅状态变化: 用户 ${userId}, 旧状态 ${oldState}, 新状态 ${newState}`);
  293. switch(newState) {
  294. case 3: // 订阅成功
  295. try {
  296. console.log('开始创建远程视频元素');
  297. // 检查是否已有该用户的视频元素
  298. if (remoteVideoElMap.current[`camera_${userId}`]) {
  299. console.log(`用户 ${userId} 的视频元素已存在`);
  300. return;
  301. }
  302. const video = document.createElement('video');
  303. video.autoplay = true;
  304. video.playsInline = true;
  305. video.className = 'w-80 h-45 mr-2 mb-2 bg-black';
  306. if (!remoteVideoContainer.current) {
  307. console.error('远程视频容器未找到');
  308. return;
  309. }
  310. // 确保容器可见
  311. remoteVideoContainer.current.style.display = 'block';
  312. remoteVideoContainer.current.appendChild(video);
  313. remoteVideoElMap.current[`camera_${userId}`] = video;
  314. // 设置远程视图配置
  315. aliRtcEngine.current!.setRemoteViewConfig(
  316. video,
  317. userId,
  318. AliRtcVideoTrack.AliRtcVideoTrackCamera
  319. );
  320. console.log(`已订阅用户 ${userId} 的视频流`);
  321. showMessage(`已显示用户 ${userId} 的视频`);
  322. } catch (err) {
  323. console.error(`订阅用户 ${userId} 视频流失败:`, err);
  324. showMessage(`订阅用户 ${userId} 视频流失败`);
  325. }
  326. break;
  327. case 1: // 取消订阅
  328. console.log(`取消订阅用户 ${userId} 的视频流`);
  329. removeRemoteVideo(userId, 'camera');
  330. break;
  331. case 2: // 订阅中
  332. console.log(`正在订阅用户 ${userId} 的视频流...`);
  333. break;
  334. default:
  335. console.warn(`未知订阅状态: ${newState}`);
  336. }
  337. });
  338. // 订阅屏幕分享流状态变化
  339. aliRtcEngine.current.on('screenShareSubscribeStateChanged', (
  340. userId: string,
  341. oldState: AliRtcSubscribeState,
  342. newState: AliRtcSubscribeState,
  343. elapseSinceLastState: number,
  344. channel: string
  345. ) => {
  346. console.log(`屏幕分享订阅状态变更:uid=${userId}, oldState=${oldState}, newState=${newState}`);
  347. switch(newState) {
  348. case 3: // 订阅成功
  349. try {
  350. console.log('开始创建屏幕分享视频元素');
  351. // 检查是否已有该用户的屏幕分享元素
  352. if (remoteVideoElMap.current[`screen_${userId}`]) {
  353. console.log(`用户 ${userId} 的屏幕分享元素已存在`);
  354. return;
  355. }
  356. const video = document.createElement('video');
  357. video.autoplay = true;
  358. video.playsInline = true;
  359. video.className = 'w-80 h-45 mr-2 mb-2 bg-black';
  360. if (!remoteVideoContainer.current) {
  361. console.error('远程视频容器未找到');
  362. return;
  363. }
  364. remoteVideoContainer.current.appendChild(video);
  365. remoteVideoElMap.current[`screen_${userId}`] = video;
  366. // 设置远程视图配置
  367. aliRtcEngine.current!.setRemoteViewConfig(
  368. video,
  369. userId,
  370. AliRtcVideoTrack.AliRtcVideoTrackScreen
  371. );
  372. console.log(`已订阅用户 ${userId} 的屏幕分享流`);
  373. showMessage(`已显示用户 ${userId} 的屏幕分享`);
  374. } catch (err) {
  375. console.error(`订阅用户 ${userId} 屏幕分享流失败:`, err);
  376. showMessage(`订阅用户 ${userId} 屏幕分享流失败`);
  377. }
  378. break;
  379. case 1: // 取消订阅
  380. console.log(`取消订阅用户 ${userId} 的屏幕分享流`);
  381. removeRemoteVideo(userId, 'screen');
  382. break;
  383. case 2: // 订阅中
  384. console.log(`正在订阅用户 ${userId} 的屏幕分享流...`);
  385. break;
  386. default:
  387. console.warn(`未知屏幕分享订阅状态: ${newState}`);
  388. }
  389. });
  390. };
  391. // 获取学生列表
  392. const fetchStudents = async (classId: string) => {
  393. try {
  394. if (!imEngine.current) {
  395. throw new Error('IM引擎未初始化');
  396. }
  397. const groupManager = imEngine.current.getGroupManager();
  398. if (!groupManager) {
  399. throw new Error('IM群组管理器未初始化');
  400. }
  401. // 使用classId作为群组ID获取成员
  402. const response = await groupManager.listRecentGroupUser(classId);
  403. // 转换IM用户数据格式
  404. const students = response.userList.map((user: ImUser) => ({
  405. id: user.userId,
  406. name: user.userExtension || `用户${user.userId}`
  407. }));
  408. setStudents(students);
  409. } catch (err) {
  410. console.error('从IM获取学生列表失败:', err);
  411. // 可选: 显示错误提示给用户
  412. // setError('获取学生列表失败,请稍后重试');
  413. }
  414. };
  415. // 统一登录逻辑
  416. const login = async (userId: string): Promise<void> => {
  417. try {
  418. // 初始化IM
  419. const { ImEngine: ImEngineClass } = window.AliVCInteraction;
  420. imEngine.current = ImEngineClass.createEngine();
  421. await imEngine.current.init({
  422. deviceId: 'xxxx',
  423. appId: IM_APP_ID,
  424. appSign: IM_APP_SIGN,
  425. logLevel: ImLogLevel.ERROR,
  426. });
  427. // 登录IM
  428. const imToken = await generateImToken(userId, role);
  429. await imEngine.current.login({
  430. user: {
  431. userId,
  432. userExtension: '{}'
  433. },
  434. userAuth: {
  435. nonce: 'AK_4',
  436. timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
  437. token: imToken,
  438. role
  439. }
  440. });
  441. // 初始化RTC
  442. aliRtcEngine.current = AliRtcEngine.getInstance();
  443. AliRtcEngine.setLogLevel(0);
  444. // 设置事件监听
  445. listenImEvents();
  446. listenRtcEvents();
  447. setIsLoggedIn(true);
  448. setErrorMessage('');
  449. showToast('success', '登录成功');
  450. // 登录成功,不生成分享链接(将在课堂创建成功后生成)
  451. } catch (err: any) {
  452. setErrorMessage(`登录失败: ${err.message}`);
  453. showToast('error', '登录失败');
  454. }
  455. };
  456. // 加入课堂
  457. const joinClass = async (classId: string): Promise<void> => {
  458. if (!imEngine.current || !aliRtcEngine.current) return;
  459. if (!classId) {
  460. setErrorMessage('课堂ID不能为空');
  461. showToast('error', '请输入有效的课堂ID');
  462. return;
  463. }
  464. try {
  465. // 加入IM群组
  466. const gm = imEngine.current.getGroupManager();
  467. const mm = imEngine.current.getMessageManager();
  468. imGroupManager.current = gm || null;
  469. imMessageManager.current = mm || null;
  470. await gm!.joinGroup(classId);
  471. listenGroupEvents();
  472. listenMessageEvents();
  473. // 加入RTC频道
  474. await joinRtcChannel(classId);
  475. setIsJoinedClass(true);
  476. setErrorMessage('');
  477. showToast('success', '加入课堂成功');
  478. } catch (err: any) {
  479. setErrorMessage(`加入课堂失败: ${err.message}`);
  480. showToast('error', '加入课堂失败');
  481. // 如果IM加入成功但RTC加入失败,需要离开IM群组
  482. if (imGroupManager.current) {
  483. try {
  484. await imGroupManager.current.leaveGroup(classId);
  485. } catch (leaveErr) {
  486. console.error('离开IM群组失败:', leaveErr);
  487. }
  488. }
  489. }
  490. };
  491. // 离开课堂
  492. const leaveClass = async (): Promise<void> => {
  493. try {
  494. if (imGroupManager.current && classId) {
  495. await imGroupManager.current.leaveGroup(classId);
  496. }
  497. if (aliRtcEngine.current) {
  498. await leaveRtcChannel();
  499. }
  500. setIsJoinedClass(false);
  501. showToast('info', '已离开课堂');
  502. } catch (err) {
  503. console.error('离开课堂失败:', err);
  504. showToast('error', '离开课堂时发生错误');
  505. }
  506. };
  507. // 发送消息
  508. const sendMessage = async (): Promise<void> => {
  509. if (!imMessageManager.current || !classId) return;
  510. try {
  511. await imMessageManager.current.sendGroupMessage({
  512. groupId: classId,
  513. data: msgText,
  514. type: 88888,
  515. level: ImMessageLevel.NORMAL,
  516. });
  517. setMsgText('');
  518. setErrorMessage('');
  519. } catch (err: any) {
  520. setErrorMessage(`消息发送失败: ${err.message}`);
  521. }
  522. };
  523. // 开始上课
  524. const startClass = async (): Promise<void> => {
  525. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  526. try {
  527. // 发送开始上课消息
  528. await imMessageManager.current.sendGroupMessage({
  529. groupId: classId,
  530. data: JSON.stringify({ action: 'start_class' }),
  531. type: 88889, // 自定义消息类型
  532. level: ImMessageLevel.HIGH,
  533. });
  534. setClassStatus(ClassStatus.IN_PROGRESS);
  535. showToast('success', '课堂已开始');
  536. } catch (err: any) {
  537. setErrorMessage(`开始上课失败: ${err.message}`);
  538. }
  539. };
  540. // 结束上课
  541. const endClass = async (): Promise<void> => {
  542. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  543. try {
  544. await imMessageManager.current.sendGroupMessage({
  545. groupId: classId,
  546. data: JSON.stringify({ action: 'end_class' }),
  547. type: 88889, // 自定义消息类型
  548. level: ImMessageLevel.HIGH,
  549. });
  550. setClassStatus(ClassStatus.ENDED);
  551. showToast('success', '课堂已结束');
  552. // 离开RTC频道
  553. try {
  554. await leaveRtcChannel();
  555. } catch (err: any) {
  556. console.error('离开RTC频道失败:', err);
  557. showToast('error', '离开RTC频道失败');
  558. }
  559. } catch (err: any) {
  560. setErrorMessage(`结束上课失败: ${err.message}`);
  561. }
  562. };
  563. // 静音/取消静音成员
  564. const toggleMuteMember = async (userId: string, mute: boolean): Promise<void> => {
  565. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  566. try {
  567. await imMessageManager.current.sendGroupMessage({
  568. groupId: classId,
  569. data: JSON.stringify({
  570. action: 'toggle_mute',
  571. userId,
  572. mute
  573. }),
  574. type: 88890, // 自定义消息类型
  575. level: ImMessageLevel.HIGH,
  576. });
  577. showToast('info', mute ? `已静音用户 ${userId}` : `已取消静音用户 ${userId}`);
  578. } catch (err: any) {
  579. setErrorMessage(`操作失败: ${err.message}`);
  580. }
  581. };
  582. // 创建课堂
  583. const createClass = async (className: string, maxMembers = 200): Promise<string | null> => {
  584. if (!imEngine.current || !isLoggedIn || role !== Role.Teacher) {
  585. showToast('error', '只有老师可以创建课堂');
  586. return null;
  587. }
  588. try {
  589. const groupManager = imEngine.current.getGroupManager();
  590. if (!groupManager) {
  591. throw new Error('群组管理器未初始化');
  592. }
  593. // 显示创建中状态
  594. showToast('info', '正在创建课堂...');
  595. // 调用IM SDK创建群组
  596. const response = await groupManager.createGroup({
  597. groupName: className,
  598. groupMeta: JSON.stringify({
  599. classType: 'interactive',
  600. creator: userId,
  601. createdAt: Date.now(),
  602. maxMembers
  603. })
  604. });
  605. if (!response?.groupId) {
  606. throw new Error('创建群组失败: 未返回群组ID');
  607. }
  608. // 创建成功后自动加入群组
  609. try {
  610. await groupManager.joinGroup(response.groupId);
  611. showToast('success', '课堂创建并加入成功');
  612. showMessage(`课堂 ${className} 创建成功,ID: ${response.groupId}`);
  613. // 更新状态
  614. setClassId(response.groupId);
  615. setIsJoinedClass(true);
  616. // 初始化群组消息管理器
  617. const messageManager = imEngine.current.getMessageManager();
  618. if (messageManager) {
  619. imMessageManager.current = messageManager;
  620. listenMessageEvents();
  621. }
  622. // 加入RTC频道
  623. await joinRtcChannel(response.groupId);
  624. // 记录创建时间
  625. const createTime = new Date();
  626. showMessage(`创建时间: ${createTime.toLocaleString()}`);
  627. // 创建成功后生成分享链接
  628. setShareLink(`${window.location.href.split('?')[0]}?classId=${response.groupId}`);
  629. return response.groupId;
  630. } catch (joinErr: any) {
  631. throw new Error(`创建成功但加入失败: ${joinErr.message}`);
  632. }
  633. } catch (err: any) {
  634. const errorMsg = err.message.includes('alreadyExist')
  635. ? '课堂已存在'
  636. : `课堂创建失败: ${err.message}`;
  637. setErrorMessage(errorMsg);
  638. showToast('error', errorMsg);
  639. return null;
  640. }
  641. };
  642. // 加入RTC频道
  643. const joinRtcChannel = async (classId: string, publishOptions?: {
  644. publishVideo?: boolean
  645. publishAudio?: boolean
  646. publishScreen?: boolean
  647. }) => {
  648. if (!aliRtcEngine.current) return;
  649. const {
  650. publishVideo = false,
  651. publishAudio = false,
  652. publishScreen = false,
  653. } = publishOptions || {};
  654. const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
  655. const token = await generateToken(RTC_APP_ID, RTC_APP_KEY, classId, userId, timestamp);
  656. await aliRtcEngine.current.publishLocalVideoStream(publishVideo);
  657. await aliRtcEngine.current.publishLocalAudioStream(publishAudio);
  658. await aliRtcEngine.current.publishLocalScreenShareStream(publishScreen);
  659. await aliRtcEngine.current.joinChannel(
  660. {
  661. channelId: classId,
  662. userId,
  663. appId: RTC_APP_ID,
  664. token,
  665. timestamp,
  666. },
  667. userId
  668. );
  669. // showToast('info', '已加入RTC频道');
  670. };
  671. // 离开RTC频道
  672. const leaveRtcChannel = async () => {
  673. if (!aliRtcEngine.current) return;
  674. await aliRtcEngine.current.leaveChannel();
  675. // showToast('info', '已离开RTC频道');
  676. };
  677. // 切换摄像头状态
  678. const toggleCamera = async () => {
  679. try {
  680. if (isCameraOn) {
  681. await leaveRtcChannel();
  682. await joinRtcChannel(classId, {
  683. publishVideo: false,
  684. publishAudio: isAudioOn,
  685. publishScreen: isScreenSharing
  686. });
  687. await aliRtcEngine.current?.stopPreview();
  688. } else {
  689. await leaveRtcChannel();
  690. await joinRtcChannel(classId, {
  691. publishVideo: true,
  692. publishAudio: isAudioOn,
  693. publishScreen: isScreenSharing
  694. });
  695. await aliRtcEngine.current?.setLocalViewConfig('localPreviewer', AliRtcVideoTrack.AliRtcVideoTrackCamera);
  696. await aliRtcEngine.current?.startPreview();
  697. }
  698. setIsCameraOn(!isCameraOn);
  699. } catch (err) {
  700. console.error('切换摄像头状态失败:', err);
  701. showToast('error', '切换摄像头失败');
  702. }
  703. };
  704. // 切换音频状态
  705. const toggleAudio = async () => {
  706. try {
  707. await leaveRtcChannel();
  708. await joinRtcChannel(classId, {
  709. publishVideo: isCameraOn,
  710. publishAudio: !isAudioOn,
  711. publishScreen: isScreenSharing
  712. });
  713. setIsAudioOn(!isAudioOn);
  714. } catch (err) {
  715. console.error('切换麦克风状态失败:', err);
  716. showToast('error', '切换麦克风失败');
  717. }
  718. };
  719. // 切换屏幕分享状态
  720. const toggleScreenShare = async () => {
  721. try {
  722. if (isScreenSharing) {
  723. await leaveRtcChannel();
  724. await joinRtcChannel(classId, {
  725. publishVideo: isCameraOn,
  726. publishAudio: isAudioOn,
  727. publishScreen: false
  728. });
  729. await aliRtcEngine.current?.stopPreviewScreen();
  730. } else {
  731. await leaveRtcChannel();
  732. // 设置屏幕分享预览视图
  733. await joinRtcChannel(classId, {
  734. publishVideo: isCameraOn,
  735. publishAudio: isAudioOn,
  736. publishScreen: true
  737. });
  738. await aliRtcEngine.current?.setLocalViewConfig(
  739. 'screenPreviewer',
  740. AliRtcVideoTrack.AliRtcVideoTrackScreen
  741. );
  742. // await aliRtcEngine.current?.startPreviewScreen({
  743. // audio: isAudioOn, // 根据音频状态决定是否共享音频
  744. // videoTrack: undefined, // 使用默认视频轨道
  745. // audioTrack: undefined // 使用默认音频轨道
  746. // });
  747. await aliRtcEngine.current?.startPreviewScreen()
  748. }
  749. setIsScreenSharing(!isScreenSharing);
  750. } catch (err) {
  751. console.error('切换屏幕分享失败:', err);
  752. showToast('error', '切换屏幕分享失败');
  753. }
  754. };
  755. // 清理资源
  756. useEffect(() => {
  757. return () => {
  758. if (imGroupManager.current) {
  759. imGroupManager.current.removeAllListeners();
  760. }
  761. if (imMessageManager.current) {
  762. imMessageManager.current.removeAllListeners();
  763. }
  764. if (imEngine.current) {
  765. imEngine.current.removeAllListeners();
  766. }
  767. if (aliRtcEngine.current) {
  768. aliRtcEngine.current.destroy();
  769. }
  770. };
  771. }, []);
  772. // 学生举手
  773. const handUp = async (question?: string): Promise<void> => {
  774. if (!imMessageManager.current || !classId || role !== 'student') return;
  775. try {
  776. await imMessageManager.current.sendGroupMessage({
  777. groupId: classId,
  778. data: JSON.stringify({
  779. action: 'hand_up',
  780. studentId: userId,
  781. timestamp: Date.now(),
  782. question
  783. }),
  784. type: 88891,
  785. level: ImMessageLevel.NORMAL,
  786. });
  787. } catch (err: any) {
  788. setErrorMessage(`举手失败: ${err.message}`);
  789. }
  790. };
  791. // 老师应答举手
  792. const answerHandUp = async (studentId: string): Promise<void> => {
  793. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  794. try {
  795. await imMessageManager.current.sendGroupMessage({
  796. groupId: classId,
  797. data: JSON.stringify({
  798. action: 'answer_hand_up',
  799. studentId
  800. }),
  801. type: 88893,
  802. level: ImMessageLevel.HIGH,
  803. });
  804. } catch (err: any) {
  805. setErrorMessage(`应答失败: ${err.message}`);
  806. }
  807. };
  808. // 发送问题
  809. const sendQuestion = async (question: string): Promise<void> => {
  810. if (!imMessageManager.current || !classId) return;
  811. try {
  812. await imMessageManager.current.sendGroupMessage({
  813. groupId: classId,
  814. data: question,
  815. type: 88892,
  816. level: ImMessageLevel.NORMAL,
  817. });
  818. } catch (err: any) {
  819. setErrorMessage(`问题发送失败: ${err.message}`);
  820. }
  821. };
  822. return (
  823. <ClassroomContext.Provider value={{
  824. userId,
  825. role,
  826. isLoggedIn,
  827. isJoinedClass,
  828. messageList,
  829. errorMessage,
  830. classStatus,
  831. handUpList,
  832. questions,
  833. setRole: (role: Role) => setRole(role as Role),
  834. createClass,
  835. startClass,
  836. endClass,
  837. toggleMuteMember,
  838. handUp,
  839. answerHandUp,
  840. sendQuestion
  841. }}>
  842. <div className="container mx-auto p-4">
  843. <h1 className="text-2xl font-bold mb-4">互动课堂</h1>
  844. <ToastContainer
  845. position="top-right"
  846. autoClose={5000}
  847. hideProgressBar={false}
  848. newestOnTop={false}
  849. closeOnClick
  850. rtl={false}
  851. pauseOnFocusLoss
  852. draggable
  853. pauseOnHover
  854. />
  855. <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
  856. <div className="md:col-span-1">
  857. {shareLink && (
  858. <div className="mb-4 p-4 bg-white rounded-md shadow">
  859. <h4 className="text-lg font-medium mb-2">课堂分享链接</h4>
  860. <div className="flex items-center">
  861. <input
  862. type="text"
  863. readOnly
  864. value={shareLink}
  865. className="flex-1 px-3 py-2 border border-gray-300 rounded-l-md"
  866. />
  867. <button
  868. type="button"
  869. className="px-3 py-2 bg-blue-600 text-white rounded-r-md"
  870. onClick={() => {
  871. navigator.clipboard.writeText(shareLink);
  872. showToast('info', '链接已复制');
  873. }}
  874. >
  875. 复制
  876. </button>
  877. </div>
  878. </div>
  879. )}
  880. <form>
  881. {!isLoggedIn && (
  882. <div className="mb-2">
  883. <label className="block text-sm font-medium text-gray-700">课堂名称</label>
  884. <input
  885. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  886. value={className}
  887. onChange={(e) => setClassName(e.target.value)}
  888. placeholder="输入课堂名称"
  889. />
  890. </div>
  891. )}
  892. <div className="mb-2">
  893. <label className="block text-sm font-medium text-gray-700">用户ID</label>
  894. <input
  895. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  896. value={userId}
  897. onChange={(e) => setUserId(e.target.value)}
  898. />
  899. </div>
  900. <div className="mb-2">
  901. <label className="block text-sm font-medium text-gray-700">课堂ID</label>
  902. <input
  903. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  904. value={classId}
  905. onChange={(e) => setClassId(e.target.value)}
  906. />
  907. </div>
  908. <div className="mb-2">
  909. <label className="block text-sm font-medium text-gray-700">角色</label>
  910. <select
  911. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  912. value={role}
  913. onChange={(e) => setRole(e.target.value as Role)}
  914. >
  915. <option value={Role.Student}>学生</option>
  916. <option value={Role.Teacher}>老师</option>
  917. </select>
  918. </div>
  919. <div className="flex space-x-2 mb-2">
  920. {!isLoggedIn && (
  921. <button
  922. type="button"
  923. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  924. onClick={() => login(userId)}
  925. >
  926. 登录
  927. </button>
  928. )}
  929. {isLoggedIn && role === Role.Teacher && (
  930. <button
  931. type="button"
  932. className="px-3 py-2 bg-green-600 text-white rounded-md"
  933. disabled={!className}
  934. onClick={async () => {
  935. const classId = await createClass(className);
  936. if (classId) {
  937. setClassId(classId);
  938. }
  939. }}
  940. >
  941. 创建课堂
  942. </button>
  943. )}
  944. <button
  945. type="button"
  946. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  947. disabled={!isLoggedIn || isJoinedClass}
  948. onClick={() => joinClass(classId)}
  949. >
  950. 加入课堂
  951. </button>
  952. <button
  953. type="button"
  954. className="px-3 py-2 bg-gray-600 text-white rounded-md"
  955. disabled={!isJoinedClass}
  956. onClick={leaveClass}
  957. >
  958. 离开课堂
  959. </button>
  960. </div>
  961. </form>
  962. <div className="mt-4">
  963. <label className="block text-sm font-medium text-gray-700">消息</label>
  964. <input
  965. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  966. value={msgText}
  967. onChange={(e) => setMsgText(e.target.value)}
  968. />
  969. <button
  970. type="button"
  971. className="mt-2 px-3 py-2 bg-blue-600 text-white rounded-md"
  972. disabled={!isJoinedClass}
  973. onClick={sendMessage}
  974. >
  975. 发送
  976. </button>
  977. </div>
  978. {role === 'student' && isJoinedClass && (
  979. <div className="mt-4 p-4 bg-white rounded-md shadow">
  980. <h4 className="text-lg font-medium mb-2">互动功能</h4>
  981. <div className="space-y-3">
  982. <button
  983. type="button"
  984. className="w-full px-3 py-2 bg-green-600 text-white rounded-md"
  985. onClick={() => handUp()}
  986. >
  987. 举手
  988. </button>
  989. <div className="flex space-x-2">
  990. <input
  991. type="text"
  992. placeholder="输入问题..."
  993. className="flex-1 px-3 py-2 border border-gray-300 rounded-md"
  994. id="questionInput"
  995. />
  996. <button
  997. type="button"
  998. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  999. onClick={() => {
  1000. const input = document.getElementById('questionInput') as HTMLInputElement;
  1001. if (input.value) {
  1002. sendQuestion(input.value);
  1003. input.value = '';
  1004. }
  1005. }}
  1006. >
  1007. 提问
  1008. </button>
  1009. </div>
  1010. </div>
  1011. </div>
  1012. )}
  1013. {role === Role.Teacher && handUpList.length > 0 && (
  1014. <div className="mt-4 p-4 bg-white rounded-md shadow">
  1015. <h4 className="text-lg font-medium mb-2">举手列表 ({handUpList.length})</h4>
  1016. <div className="space-y-2">
  1017. {handUpList.map((req, i) => (
  1018. <div key={i} className="flex items-center justify-between p-2 border-b">
  1019. <div>
  1020. <div className="font-medium">{req.studentName || req.studentId}</div>
  1021. {req.question && <div className="text-sm text-gray-500">{req.question}</div>}
  1022. </div>
  1023. <button
  1024. type="button"
  1025. className="px-2 py-1 bg-blue-600 text-white rounded text-sm"
  1026. onClick={() => answerHandUp(req.studentId)}
  1027. >
  1028. 应答
  1029. </button>
  1030. </div>
  1031. ))}
  1032. </div>
  1033. </div>
  1034. )}
  1035. {questions.length > 0 && (
  1036. <div className="mt-4 p-4 bg-white rounded-md shadow">
  1037. <h4 className="text-lg font-medium mb-2">问题列表 ({questions.length})</h4>
  1038. <div className="space-y-2">
  1039. {questions.map((q, i) => (
  1040. <div key={i} className="p-2 border-b">
  1041. <div className="font-medium">问题 {i + 1}</div>
  1042. <div className="text-gray-700">{q}</div>
  1043. </div>
  1044. ))}
  1045. </div>
  1046. </div>
  1047. )}
  1048. </div>
  1049. <div className="md:col-span-1">
  1050. <h4 className="text-lg font-medium mb-2">消息记录</h4>
  1051. <div className="bg-gray-100 p-2 rounded-md h-64 overflow-y-auto">
  1052. {messageList.map((msg, i) => (
  1053. <div key={i} className="mb-1">{msg}</div>
  1054. ))}
  1055. </div>
  1056. {role === Role.Teacher && isJoinedClass && (
  1057. <div className="mt-4 p-4 bg-white rounded-md shadow">
  1058. <h4 className="text-lg font-medium mb-2">老师控制面板</h4>
  1059. <div className="flex space-x-2 mb-4">
  1060. <button
  1061. type="button"
  1062. className="px-3 py-2 bg-green-600 text-white rounded-md"
  1063. disabled={classStatus === ClassStatus.IN_PROGRESS}
  1064. onClick={startClass}
  1065. >
  1066. 开始上课
  1067. </button>
  1068. <button
  1069. type="button"
  1070. className="px-3 py-2 bg-red-600 text-white rounded-md"
  1071. disabled={classStatus !== ClassStatus.IN_PROGRESS}
  1072. onClick={endClass}
  1073. >
  1074. 结束上课
  1075. </button>
  1076. </div>
  1077. <div>
  1078. <h5 className="font-medium mb-2">成员管理</h5>
  1079. <div className="space-y-2">
  1080. {students.map(student => (
  1081. <div key={student.id} className="flex items-center justify-between">
  1082. <span>{student.name}</span>
  1083. <div className="space-x-2">
  1084. <button
  1085. type="button"
  1086. className="px-2 py-1 bg-yellow-500 text-white rounded text-sm"
  1087. onClick={() => toggleMuteMember(student.id, true)}
  1088. >
  1089. 静音
  1090. </button>
  1091. <button
  1092. type="button"
  1093. className="px-2 py-1 bg-blue-500 text-white rounded text-sm"
  1094. onClick={() => toggleMuteMember(student.id, false)}
  1095. >
  1096. 取消静音
  1097. </button>
  1098. </div>
  1099. </div>
  1100. ))}
  1101. </div>
  1102. </div>
  1103. </div>
  1104. )}
  1105. </div>
  1106. <div className="md:col-span-1">
  1107. <div className="mb-4">
  1108. <h4 className="text-lg font-medium mb-2">本地视频</h4>
  1109. <div className="relative">
  1110. <video
  1111. id="localPreviewer"
  1112. muted
  1113. className="w-full h-48 bg-black"
  1114. ></video>
  1115. <div className="absolute bottom-2 right-2 flex space-x-2">
  1116. <button
  1117. onClick={toggleCamera}
  1118. className={`px-3 py-1 rounded-md ${isCameraOn ? 'bg-red-600' : 'bg-blue-600'} text-white`}
  1119. >
  1120. {isCameraOn ? '关闭摄像头' : '开启摄像头'}
  1121. </button>
  1122. <button
  1123. onClick={toggleAudio}
  1124. className={`px-3 py-1 rounded-md ${isAudioOn ? 'bg-red-600' : 'bg-blue-600'} text-white`}
  1125. >
  1126. {isAudioOn ? '关闭麦克风' : '开启麦克风'}
  1127. </button>
  1128. </div>
  1129. </div>
  1130. </div>
  1131. <div className="mb-4">
  1132. <h4 className="text-lg font-medium mb-2">屏幕分享</h4>
  1133. <div className="relative">
  1134. <video
  1135. id="screenPreviewer"
  1136. muted
  1137. className="w-full h-48 bg-black"
  1138. ></video>
  1139. <div className="absolute bottom-2 right-2">
  1140. <button
  1141. onClick={toggleScreenShare}
  1142. className={`px-3 py-1 rounded-md ${isScreenSharing ? 'bg-red-600' : 'bg-blue-600'} text-white`}
  1143. >
  1144. {isScreenSharing ? '停止分享' : '分享屏幕'}
  1145. </button>
  1146. </div>
  1147. </div>
  1148. </div>
  1149. <div>
  1150. <h4 className="text-lg font-medium mb-2">远程视频</h4>
  1151. <div
  1152. id="remoteVideoContainer"
  1153. ref={remoteVideoContainer}
  1154. className="grid grid-cols-2 gap-2"
  1155. ></div>
  1156. </div>
  1157. </div>
  1158. </div>
  1159. {errorMessage && (
  1160. <div className="mt-2 text-red-500">{errorMessage}</div>
  1161. )}
  1162. </div>
  1163. </ClassroomContext.Provider>
  1164. );
  1165. };