pages_classroom.tsx 37 KB

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