pages_classroom.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  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. // 课堂状态枚举
  22. enum ClassStatus {
  23. NOT_STARTED = 'not_started',
  24. IN_PROGRESS = 'in_progress',
  25. ENDED = 'ended'
  26. }
  27. // 课堂上下文类型
  28. // 互动消息类型
  29. type InteractionAction = 'hand_up' | 'cancel_hand_up' | 'answer_hand_up';
  30. // 互动消息基础接口
  31. interface InteractionMessage {
  32. action: InteractionAction;
  33. studentId: string;
  34. studentName?: string;
  35. timestamp?: number;
  36. question?: string;
  37. }
  38. // 举手请求类型
  39. interface HandUpRequest extends InteractionMessage {
  40. timestamp: number;
  41. }
  42. type ClassroomContextType = {
  43. userId: string;
  44. role: 'teacher' | 'student';
  45. isLoggedIn: boolean;
  46. isJoinedClass: boolean;
  47. messageList: string[];
  48. errorMessage: string;
  49. classStatus: ClassStatus;
  50. handUpList: HandUpRequest[]; // 举手列表
  51. questions: string[]; // 问题列表
  52. setRole: (role: 'teacher' | 'student') => void;
  53. startClass: () => Promise<void>;
  54. endClass: () => Promise<void>;
  55. toggleMuteMember: (userId: string, mute: boolean) => Promise<void>;
  56. handUp: (question?: string) => Promise<void>; // 学生举手
  57. answerHandUp: (studentId: string) => Promise<void>; // 老师应答
  58. sendQuestion: (question: string) => Promise<void>; // 发送问题
  59. };
  60. const ClassroomContext = createContext<ClassroomContextType | null>(null);
  61. // 辅助函数
  62. function hex(buffer: ArrayBuffer): string {
  63. const hexCodes = [];
  64. const view = new DataView(buffer);
  65. for (let i = 0; i < view.byteLength; i += 4) {
  66. const value = view.getUint32(i);
  67. const stringValue = value.toString(16);
  68. const padding = '00000000';
  69. const paddedValue = (padding + stringValue).slice(-padding.length);
  70. hexCodes.push(paddedValue);
  71. }
  72. return hexCodes.join('');
  73. }
  74. async function generateToken(
  75. appId: string,
  76. appKey: string,
  77. channelId: string,
  78. userId: string,
  79. timestamp: number
  80. ): Promise<string> {
  81. const encoder = new TextEncoder();
  82. const data = encoder.encode(`${appId}${appKey}${channelId}${userId}${timestamp}`);
  83. const hash = await crypto.subtle.digest('SHA-256', data);
  84. return hex(hash);
  85. }
  86. function showToast(type: 'info' | 'success' | 'error', message: string): void {
  87. switch(type) {
  88. case 'info':
  89. toast.info(message);
  90. break;
  91. case 'success':
  92. toast.success(message);
  93. break;
  94. case 'error':
  95. toast.error(message);
  96. break;
  97. }
  98. }
  99. // 从SDK获取枚举值
  100. const { ImLogLevel, ImMessageLevel } = window.AliVCInteraction;
  101. // 配置信息
  102. const IM_APP_ID = '4c2ab5e1b1b0';
  103. const IM_APP_KEY = '314bb5eee5b623549e8a41574ba3ff32';
  104. const IM_APP_SIGN = 'H4sIAAAAAAAE/wCQAG//zguHB+lYCilkv7diSkk4GmcvLuds+InRu9vFOFebMwm/jEgsK5bBT85Z0owObMxG58uXHyPFlPEBEDQm9FswNJ+KmX0VDYkcfdPPWkafA6Hc0B6F+p5De9yJfPEfHzwo/DHMaygbHfLmBgUtmKveq421sJr/gNBz9D04Ewsg39us+ao0NegzLt7xtXvFXXXJAAAA//8BAAD//yoav6aQAAAA';
  105. const RTC_APP_ID = 'a5842c2a-d94a-43be-81de-1fdb712476e1';
  106. const RTC_APP_KEY = 'b71d65f4f84c450f6f058f4ad507bd42';
  107. export const ClassroomPage = () => {
  108. // 状态管理
  109. const [userId, setUserId] = useState<string>('');
  110. const [role, setRole] = useState<'teacher' | 'student'>('student');
  111. const [classId, setClassId] = useState<string>('');
  112. const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
  113. const [isJoinedClass, setIsJoinedClass] = useState<boolean>(false);
  114. const [msgText, setMsgText] = useState<string>('');
  115. const [messageList, setMessageList] = useState<string[]>([]);
  116. const [errorMessage, setErrorMessage] = useState<string>('');
  117. const [classStatus, setClassStatus] = useState<ClassStatus>(ClassStatus.NOT_STARTED);
  118. const [handUpList, setHandUpList] = useState<HandUpRequest[]>([]);
  119. const [questions, setQuestions] = useState<string[]>([]);
  120. // SDK实例
  121. const imEngine = useRef<ImEngine | null>(null);
  122. const imGroupManager = useRef<ImGroupManager | null>(null);
  123. const imMessageManager = useRef<ImMessageManager | null>(null);
  124. const aliRtcEngine = useRef<AliRtcEngine | null>(null);
  125. const remoteVideoElMap = useRef<Record<string, HTMLVideoElement>>({});
  126. const remoteVideoContainer = useRef<HTMLDivElement>(null);
  127. // 消息管理模块
  128. const showMessage = (text: string): void => {
  129. setMessageList([...messageList, text]);
  130. };
  131. const listenImEvents = (): void => {
  132. if (!imEngine.current) return;
  133. imEngine.current.on('connectsuccess', () => {
  134. showMessage('IM连接成功');
  135. });
  136. imEngine.current.on('disconnect', (code: number) => {
  137. showMessage(`IM断开连接: ${code}`);
  138. });
  139. };
  140. const listenGroupEvents = (): void => {
  141. if (!imGroupManager.current) return;
  142. imGroupManager.current.on('memberchange', (groupId: string, memberCount: number, joinUsers: ImUser[], leaveUsers: ImUser[]) => {
  143. showMessage(`成员变更: 加入${joinUsers.length}人, 离开${leaveUsers.length}人`);
  144. });
  145. };
  146. const listenMessageEvents = (): void => {
  147. if (!imMessageManager.current) return;
  148. imMessageManager.current.on('recvgroupmessage', (msg: AliVCInteraction.ImMessage, groupId: string) => {
  149. if (msg.type === 88889) { // 课堂状态消息
  150. try {
  151. const data = JSON.parse(msg.data);
  152. if (data.action === 'start_class') {
  153. setClassStatus(ClassStatus.IN_PROGRESS);
  154. showMessage('老师已开始上课');
  155. } else if (data.action === 'end_class') {
  156. setClassStatus(ClassStatus.ENDED);
  157. showMessage('老师已结束上课');
  158. }
  159. } catch (err) {
  160. console.error('解析课堂状态消息失败', err);
  161. }
  162. } else if (msg.type === 88890) { // 静音指令
  163. try {
  164. const data = JSON.parse(msg.data);
  165. if (data.action === 'toggle_mute' && data.userId === userId) {
  166. showMessage(data.mute ? '你已被老师静音' : '老师已取消你的静音');
  167. }
  168. } catch (err) {
  169. console.error('解析静音指令失败', err);
  170. }
  171. } else if (msg.type === 88891) { // 举手消息
  172. try {
  173. const data = JSON.parse(msg.data) as InteractionMessage;
  174. if (data.action === 'hand_up') {
  175. const handUpData: HandUpRequest = {
  176. ...data,
  177. timestamp: data.timestamp || Date.now()
  178. };
  179. setHandUpList([...handUpList, handUpData]);
  180. showMessage(`${data.studentName || data.studentId} 举手了`);
  181. } else if (data.action === 'cancel_hand_up') {
  182. setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
  183. }
  184. } catch (err) {
  185. console.error('解析举手消息失败', err);
  186. }
  187. } else if (msg.type === 88892) { // 问题消息
  188. try {
  189. const data = JSON.parse(msg.data) as {question: string};
  190. setQuestions([...questions, data.question]);
  191. showMessage(`收到问题: ${data.question}`);
  192. } catch (err) {
  193. console.error('解析问题消息失败', err);
  194. }
  195. } else if (msg.type === 88893) { // 应答消息
  196. try {
  197. const data = JSON.parse(msg.data) as InteractionMessage;
  198. if (data.action === 'answer_hand_up' && data.studentId === userId) {
  199. showMessage('老师已应答你的举手');
  200. setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
  201. }
  202. } catch (err) {
  203. console.error('解析应答消息失败', err);
  204. }
  205. }
  206. });
  207. };
  208. // 音视频模块
  209. const removeRemoteVideo = (userId: string, type: 'camera' | 'screen' = 'camera') => {
  210. const vid = `${type}_${userId}`;
  211. const el = remoteVideoElMap.current[vid];
  212. if (el) {
  213. aliRtcEngine.current!.setRemoteViewConfig(null, userId, type === 'camera' ? AliRtcVideoTrack.AliRtcVideoTrackCamera : AliRtcVideoTrack.AliRtcVideoTrackScreen);
  214. el.pause();
  215. remoteVideoContainer.current?.removeChild(el);
  216. delete remoteVideoElMap.current[vid];
  217. }
  218. };
  219. const listenRtcEvents = () => {
  220. if (!aliRtcEngine.current) return;
  221. aliRtcEngine.current.on('remoteUserOnLineNotify', (userId: string) => {
  222. showMessage(`用户 ${userId} 加入课堂`);
  223. });
  224. aliRtcEngine.current.on('remoteUserOffLineNotify', (userId: string) => {
  225. showMessage(`用户 ${userId} 离开课堂`);
  226. removeRemoteVideo(userId, 'camera');
  227. removeRemoteVideo(userId, 'screen');
  228. });
  229. };
  230. // 统一登录逻辑
  231. const login = async (userId: string): Promise<void> => {
  232. try {
  233. // 初始化IM
  234. const { ImEngine: ImEngineClass } = window.AliVCInteraction;
  235. imEngine.current = ImEngineClass.createEngine();
  236. await imEngine.current.init({
  237. deviceId: 'xxxx',
  238. appId: IM_APP_ID,
  239. appSign: IM_APP_SIGN,
  240. logLevel: ImLogLevel.ERROR,
  241. });
  242. // 初始化RTC
  243. aliRtcEngine.current = AliRtcEngine.getInstance();
  244. AliRtcEngine.setLogLevel(0);
  245. // 设置事件监听
  246. listenImEvents();
  247. listenRtcEvents();
  248. setIsLoggedIn(true);
  249. setErrorMessage('');
  250. showToast('success', '登录成功');
  251. } catch (err: any) {
  252. setErrorMessage(`登录失败: ${err.message}`);
  253. showToast('error', '登录失败');
  254. }
  255. };
  256. // 加入课堂
  257. const joinClass = async (classId: string): Promise<void> => {
  258. if (!imEngine.current || !aliRtcEngine.current) return;
  259. try {
  260. // 加入IM群组
  261. const gm = imEngine.current.getGroupManager();
  262. const mm = imEngine.current.getMessageManager();
  263. imGroupManager.current = gm || null;
  264. imMessageManager.current = mm || null;
  265. await gm!.joinGroup(classId);
  266. listenGroupEvents();
  267. listenMessageEvents();
  268. // 加入RTC频道
  269. const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
  270. const token = await generateToken(RTC_APP_ID, RTC_APP_KEY, classId, userId, timestamp);
  271. aliRtcEngine.current.setChannelProfile(AliRtcSdkChannelProfile.AliRtcSdkCommunication);
  272. await aliRtcEngine.current.joinChannel(
  273. {
  274. channelId: classId,
  275. userId,
  276. appId: RTC_APP_ID,
  277. token,
  278. timestamp,
  279. },
  280. userId
  281. );
  282. // 设置本地预览
  283. aliRtcEngine.current.setLocalViewConfig('localPreviewer', AliRtcVideoTrack.AliRtcVideoTrackCamera);
  284. setIsJoinedClass(true);
  285. setErrorMessage('');
  286. showToast('success', '加入课堂成功');
  287. } catch (err: any) {
  288. setErrorMessage(`加入课堂失败: ${err.message}`);
  289. showToast('error', '加入课堂失败');
  290. }
  291. };
  292. // 离开课堂
  293. const leaveClass = async (): Promise<void> => {
  294. if (imGroupManager.current && classId) {
  295. await imGroupManager.current.leaveGroup(classId);
  296. }
  297. if (aliRtcEngine.current) {
  298. await aliRtcEngine.current.leaveChannel();
  299. }
  300. setIsJoinedClass(false);
  301. showToast('info', '已离开课堂');
  302. };
  303. // 发送消息
  304. const sendMessage = async (): Promise<void> => {
  305. if (!imMessageManager.current || !classId) return;
  306. try {
  307. await imMessageManager.current.sendGroupMessage({
  308. groupId: classId,
  309. data: msgText,
  310. type: 88888,
  311. level: ImMessageLevel.NORMAL,
  312. });
  313. setMsgText('');
  314. setErrorMessage('');
  315. } catch (err: any) {
  316. setErrorMessage(`消息发送失败: ${err.message}`);
  317. }
  318. };
  319. // 开始上课
  320. const startClass = async (): Promise<void> => {
  321. if (!imMessageManager.current || !classId || role !== 'teacher') return;
  322. try {
  323. await imMessageManager.current.sendGroupMessage({
  324. groupId: classId,
  325. data: JSON.stringify({ action: 'start_class' }),
  326. type: 88889, // 自定义消息类型
  327. level: ImMessageLevel.HIGH,
  328. });
  329. setClassStatus(ClassStatus.IN_PROGRESS);
  330. showToast('success', '课堂已开始');
  331. } catch (err: any) {
  332. setErrorMessage(`开始上课失败: ${err.message}`);
  333. }
  334. };
  335. // 结束上课
  336. const endClass = async (): Promise<void> => {
  337. if (!imMessageManager.current || !classId || role !== 'teacher') return;
  338. try {
  339. await imMessageManager.current.sendGroupMessage({
  340. groupId: classId,
  341. data: JSON.stringify({ action: 'end_class' }),
  342. type: 88889, // 自定义消息类型
  343. level: ImMessageLevel.HIGH,
  344. });
  345. setClassStatus(ClassStatus.ENDED);
  346. showToast('success', '课堂已结束');
  347. } catch (err: any) {
  348. setErrorMessage(`结束上课失败: ${err.message}`);
  349. }
  350. };
  351. // 静音/取消静音成员
  352. const toggleMuteMember = async (userId: string, mute: boolean): Promise<void> => {
  353. if (!imMessageManager.current || !classId || role !== 'teacher') return;
  354. try {
  355. await imMessageManager.current.sendGroupMessage({
  356. groupId: classId,
  357. data: JSON.stringify({
  358. action: 'toggle_mute',
  359. userId,
  360. mute
  361. }),
  362. type: 88890, // 自定义消息类型
  363. level: ImMessageLevel.HIGH,
  364. });
  365. showToast('info', mute ? `已静音用户 ${userId}` : `已取消静音用户 ${userId}`);
  366. } catch (err: any) {
  367. setErrorMessage(`操作失败: ${err.message}`);
  368. }
  369. };
  370. // 清理资源
  371. useEffect(() => {
  372. return () => {
  373. if (imGroupManager.current) {
  374. imGroupManager.current.removeAllListeners();
  375. }
  376. if (imMessageManager.current) {
  377. imMessageManager.current.removeAllListeners();
  378. }
  379. if (imEngine.current) {
  380. imEngine.current.removeAllListeners();
  381. }
  382. if (aliRtcEngine.current) {
  383. aliRtcEngine.current.destroy();
  384. }
  385. };
  386. }, []);
  387. // 学生举手
  388. const handUp = async (question?: string): Promise<void> => {
  389. if (!imMessageManager.current || !classId || role !== 'student') return;
  390. try {
  391. await imMessageManager.current.sendGroupMessage({
  392. groupId: classId,
  393. data: JSON.stringify({
  394. action: 'hand_up',
  395. studentId: userId,
  396. timestamp: Date.now(),
  397. question
  398. }),
  399. type: 88891,
  400. level: ImMessageLevel.NORMAL,
  401. });
  402. } catch (err: any) {
  403. setErrorMessage(`举手失败: ${err.message}`);
  404. }
  405. };
  406. // 老师应答举手
  407. const answerHandUp = async (studentId: string): Promise<void> => {
  408. if (!imMessageManager.current || !classId || role !== 'teacher') return;
  409. try {
  410. await imMessageManager.current.sendGroupMessage({
  411. groupId: classId,
  412. data: JSON.stringify({
  413. action: 'answer_hand_up',
  414. studentId
  415. }),
  416. type: 88893,
  417. level: ImMessageLevel.HIGH,
  418. });
  419. } catch (err: any) {
  420. setErrorMessage(`应答失败: ${err.message}`);
  421. }
  422. };
  423. // 发送问题
  424. const sendQuestion = async (question: string): Promise<void> => {
  425. if (!imMessageManager.current || !classId) return;
  426. try {
  427. await imMessageManager.current.sendGroupMessage({
  428. groupId: classId,
  429. data: question,
  430. type: 88892,
  431. level: ImMessageLevel.NORMAL,
  432. });
  433. } catch (err: any) {
  434. setErrorMessage(`问题发送失败: ${err.message}`);
  435. }
  436. };
  437. return (
  438. <ClassroomContext.Provider value={{
  439. userId,
  440. role,
  441. isLoggedIn,
  442. isJoinedClass,
  443. messageList,
  444. errorMessage,
  445. classStatus,
  446. handUpList,
  447. questions,
  448. setRole,
  449. startClass,
  450. endClass,
  451. toggleMuteMember,
  452. handUp,
  453. answerHandUp,
  454. sendQuestion
  455. }}>
  456. <div className="container mx-auto p-4">
  457. <h1 className="text-2xl font-bold mb-4">互动课堂</h1>
  458. <ToastContainer
  459. position="top-right"
  460. autoClose={5000}
  461. hideProgressBar={false}
  462. newestOnTop={false}
  463. closeOnClick
  464. rtl={false}
  465. pauseOnFocusLoss
  466. draggable
  467. pauseOnHover
  468. />
  469. <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
  470. <div className="md:col-span-1">
  471. <form>
  472. <div className="mb-2">
  473. <label className="block text-sm font-medium text-gray-700">用户ID</label>
  474. <input
  475. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  476. value={userId}
  477. onChange={(e) => setUserId(e.target.value)}
  478. />
  479. </div>
  480. <div className="mb-2">
  481. <label className="block text-sm font-medium text-gray-700">课堂ID</label>
  482. <input
  483. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  484. value={classId}
  485. onChange={(e) => setClassId(e.target.value)}
  486. />
  487. </div>
  488. <div className="mb-2">
  489. <label className="block text-sm font-medium text-gray-700">角色</label>
  490. <select
  491. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  492. value={role}
  493. onChange={(e) => setRole(e.target.value as 'teacher' | 'student')}
  494. >
  495. <option value="student">学生</option>
  496. <option value="teacher">老师</option>
  497. </select>
  498. </div>
  499. <div className="flex space-x-2 mb-2">
  500. <button
  501. type="button"
  502. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  503. disabled={isLoggedIn}
  504. onClick={() => login(userId)}
  505. >
  506. 登录
  507. </button>
  508. <button
  509. type="button"
  510. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  511. disabled={!isLoggedIn || isJoinedClass}
  512. onClick={() => joinClass(classId)}
  513. >
  514. 加入课堂
  515. </button>
  516. <button
  517. type="button"
  518. className="px-3 py-2 bg-gray-600 text-white rounded-md"
  519. disabled={!isJoinedClass}
  520. onClick={leaveClass}
  521. >
  522. 离开课堂
  523. </button>
  524. </div>
  525. </form>
  526. <div className="mt-4">
  527. <label className="block text-sm font-medium text-gray-700">消息</label>
  528. <input
  529. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  530. value={msgText}
  531. onChange={(e) => setMsgText(e.target.value)}
  532. />
  533. <button
  534. type="button"
  535. className="mt-2 px-3 py-2 bg-blue-600 text-white rounded-md"
  536. disabled={!isJoinedClass}
  537. onClick={sendMessage}
  538. >
  539. 发送
  540. </button>
  541. </div>
  542. </div>
  543. <div className="md:col-span-1">
  544. <h4 className="text-lg font-medium mb-2">消息记录</h4>
  545. <div className="bg-gray-100 p-2 rounded-md h-64 overflow-y-auto">
  546. {messageList.map((msg, i) => (
  547. <div key={i} className="mb-1">{msg}</div>
  548. ))}
  549. </div>
  550. {role === 'teacher' && isJoinedClass && (
  551. <div className="mt-4 p-4 bg-white rounded-md shadow">
  552. <h4 className="text-lg font-medium mb-2">老师控制面板</h4>
  553. <div className="flex space-x-2 mb-4">
  554. <button
  555. type="button"
  556. className="px-3 py-2 bg-green-600 text-white rounded-md"
  557. disabled={classStatus === ClassStatus.IN_PROGRESS}
  558. onClick={startClass}
  559. >
  560. 开始上课
  561. </button>
  562. <button
  563. type="button"
  564. className="px-3 py-2 bg-red-600 text-white rounded-md"
  565. disabled={classStatus !== ClassStatus.IN_PROGRESS}
  566. onClick={endClass}
  567. >
  568. 结束上课
  569. </button>
  570. </div>
  571. <div>
  572. <h5 className="font-medium mb-2">成员管理</h5>
  573. <div className="space-y-2">
  574. <div className="flex items-center justify-between">
  575. <span>学生A</span>
  576. <button
  577. type="button"
  578. className="px-2 py-1 bg-yellow-500 text-white rounded text-sm"
  579. onClick={() => toggleMuteMember('studentA', true)}
  580. >
  581. 静音
  582. </button>
  583. </div>
  584. <div className="flex items-center justify-between">
  585. <span>学生B</span>
  586. <button
  587. type="button"
  588. className="px-2 py-1 bg-blue-500 text-white rounded text-sm"
  589. onClick={() => toggleMuteMember('studentB', false)}
  590. >
  591. 取消静音
  592. </button>
  593. </div>
  594. </div>
  595. </div>
  596. </div>
  597. )}
  598. </div>
  599. <div className="md:col-span-1">
  600. <h4 className="text-lg font-medium mb-2">视频区域</h4>
  601. <video
  602. id="localPreviewer"
  603. muted
  604. className="w-full h-48 bg-black mb-2"
  605. ></video>
  606. <div
  607. id="remoteVideoContainer"
  608. ref={remoteVideoContainer}
  609. className="grid grid-cols-2 gap-2"
  610. ></div>
  611. </div>
  612. </div>
  613. {errorMessage && (
  614. <div className="mt-2 text-red-500">{errorMessage}</div>
  615. )}
  616. </div>
  617. </ClassroomContext.Provider>
  618. );
  619. };