pages_classroom.tsx 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983
  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 [className, setClassName] = useState<string>('');
  135. const [role, setRole] = useState<Role>(Role.Student);
  136. const [classId, setClassId] = useState<string>('');
  137. const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
  138. const [isJoinedClass, setIsJoinedClass] = useState<boolean>(false);
  139. const [msgText, setMsgText] = useState<string>('');
  140. const [messageList, setMessageList] = useState<string[]>([]);
  141. const [errorMessage, setErrorMessage] = useState<string>('');
  142. const [classStatus, setClassStatus] = useState<ClassStatus>(ClassStatus.NOT_STARTED);
  143. const [handUpList, setHandUpList] = useState<HandUpRequest[]>([]);
  144. const [questions, setQuestions] = useState<string[]>([]);
  145. const [students, setStudents] = useState<Array<{id: string, name: string}>>([]);
  146. const [shareLink, setShareLink] = useState<string>('');
  147. // SDK实例
  148. const imEngine = useRef<ImEngine | null>(null);
  149. const imGroupManager = useRef<ImGroupManager | null>(null);
  150. const imMessageManager = useRef<ImMessageManager | null>(null);
  151. const aliRtcEngine = useRef<AliRtcEngine | null>(null);
  152. const remoteVideoElMap = useRef<Record<string, HTMLVideoElement>>({});
  153. const remoteVideoContainer = useRef<HTMLDivElement>(null);
  154. // 消息管理模块
  155. const showMessage = (text: string): void => {
  156. setMessageList([...messageList, text]);
  157. };
  158. const listenImEvents = (): void => {
  159. if (!imEngine.current) return;
  160. imEngine.current.on('connectsuccess', () => {
  161. showMessage('IM连接成功');
  162. });
  163. imEngine.current.on('disconnect', async (code: number) => {
  164. showMessage(`IM断开连接: ${code}`);
  165. // 自动重连
  166. try {
  167. const imToken = await generateImToken(userId, role);
  168. await imEngine.current!.login({
  169. user: {
  170. userId,
  171. userExtension: '{}'
  172. },
  173. userAuth: {
  174. nonce: 'AK_4',
  175. timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
  176. token: imToken,
  177. role
  178. }
  179. });
  180. showMessage('IM自动重连成功');
  181. } catch (err: unknown) {
  182. const error = err as Error;
  183. showMessage(`IM自动重连失败: ${error.message}`);
  184. }
  185. });
  186. };
  187. const listenGroupEvents = (): void => {
  188. if (!imGroupManager.current) return;
  189. imGroupManager.current.on('memberchange', (groupId: string, memberCount: number, joinUsers: ImUser[], leaveUsers: ImUser[]) => {
  190. showMessage(`成员变更: 加入${joinUsers.length}人, 离开${leaveUsers.length}人`);
  191. });
  192. };
  193. const listenMessageEvents = (): void => {
  194. if (!imMessageManager.current) return;
  195. imMessageManager.current.on('recvgroupmessage', (msg: AliVCInteraction.ImMessage, groupId: string) => {
  196. if (msg.type === 88889) { // 课堂状态消息
  197. try {
  198. const data = JSON.parse(msg.data);
  199. if (data.action === 'start_class') {
  200. setClassStatus(ClassStatus.IN_PROGRESS);
  201. showMessage('老师已开始上课');
  202. } else if (data.action === 'end_class') {
  203. setClassStatus(ClassStatus.ENDED);
  204. showMessage('老师已结束上课');
  205. }
  206. } catch (err) {
  207. console.error('解析课堂状态消息失败', err);
  208. }
  209. } else if (msg.type === 88890) { // 静音指令
  210. try {
  211. const data = JSON.parse(msg.data);
  212. if (data.action === 'toggle_mute' && data.userId === userId) {
  213. showMessage(data.mute ? '你已被老师静音' : '老师已取消你的静音');
  214. }
  215. } catch (err) {
  216. console.error('解析静音指令失败', err);
  217. }
  218. } else if (msg.type === 88891) { // 举手消息
  219. try {
  220. const data = JSON.parse(msg.data) as InteractionMessage;
  221. if (data.action === 'hand_up') {
  222. const handUpData: HandUpRequest = {
  223. ...data,
  224. timestamp: data.timestamp || Date.now()
  225. };
  226. setHandUpList([...handUpList, handUpData]);
  227. showMessage(`${data.studentName || data.studentId} 举手了`);
  228. } else if (data.action === 'cancel_hand_up') {
  229. setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
  230. }
  231. } catch (err) {
  232. console.error('解析举手消息失败', err);
  233. }
  234. } else if (msg.type === 88892) { // 问题消息
  235. try {
  236. const data = JSON.parse(msg.data) as {question: string};
  237. setQuestions([...questions, data.question]);
  238. showMessage(`收到问题: ${data.question}`);
  239. } catch (err) {
  240. console.error('解析问题消息失败', err);
  241. }
  242. } else if (msg.type === 88893) { // 应答消息
  243. try {
  244. const data = JSON.parse(msg.data) as InteractionMessage;
  245. if (data.action === 'answer_hand_up' && data.studentId === userId) {
  246. showMessage('老师已应答你的举手');
  247. setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
  248. }
  249. } catch (err) {
  250. console.error('解析应答消息失败', err);
  251. }
  252. }
  253. });
  254. };
  255. // 音视频模块
  256. const removeRemoteVideo = (userId: string, type: 'camera' | 'screen' = 'camera') => {
  257. const vid = `${type}_${userId}`;
  258. const el = remoteVideoElMap.current[vid];
  259. if (el) {
  260. aliRtcEngine.current!.setRemoteViewConfig(null, userId, type === 'camera' ? AliRtcVideoTrack.AliRtcVideoTrackCamera : AliRtcVideoTrack.AliRtcVideoTrackScreen);
  261. el.pause();
  262. remoteVideoContainer.current?.removeChild(el);
  263. delete remoteVideoElMap.current[vid];
  264. }
  265. };
  266. const listenRtcEvents = () => {
  267. if (!aliRtcEngine.current) return;
  268. aliRtcEngine.current.on('remoteUserOnLineNotify', (userId: string) => {
  269. showMessage(`用户 ${userId} 加入课堂`);
  270. });
  271. aliRtcEngine.current.on('remoteUserOffLineNotify', (userId: string) => {
  272. showMessage(`用户 ${userId} 离开课堂`);
  273. removeRemoteVideo(userId, 'camera');
  274. removeRemoteVideo(userId, 'screen');
  275. });
  276. };
  277. // 获取学生列表
  278. const fetchStudents = async (classId: string) => {
  279. try {
  280. if (!imEngine.current) {
  281. throw new Error('IM引擎未初始化');
  282. }
  283. const groupManager = imEngine.current.getGroupManager();
  284. if (!groupManager) {
  285. throw new Error('IM群组管理器未初始化');
  286. }
  287. // 使用classId作为群组ID获取成员
  288. const response = await groupManager.listRecentGroupUser(classId);
  289. // 转换IM用户数据格式
  290. const students = response.userList.map((user: ImUser) => ({
  291. id: user.userId,
  292. name: user.userExtension || `用户${user.userId}`
  293. }));
  294. setStudents(students);
  295. } catch (err) {
  296. console.error('从IM获取学生列表失败:', err);
  297. // 可选: 显示错误提示给用户
  298. // setError('获取学生列表失败,请稍后重试');
  299. }
  300. };
  301. // 统一登录逻辑
  302. const login = async (userId: string): Promise<void> => {
  303. try {
  304. // 初始化IM
  305. const { ImEngine: ImEngineClass } = window.AliVCInteraction;
  306. imEngine.current = ImEngineClass.createEngine();
  307. await imEngine.current.init({
  308. deviceId: 'xxxx',
  309. appId: IM_APP_ID,
  310. appSign: IM_APP_SIGN,
  311. logLevel: ImLogLevel.ERROR,
  312. });
  313. // 登录IM
  314. const imToken = await generateImToken(userId, role);
  315. await imEngine.current.login({
  316. user: {
  317. userId,
  318. userExtension: '{}'
  319. },
  320. userAuth: {
  321. nonce: 'AK_4',
  322. timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
  323. token: imToken,
  324. role
  325. }
  326. });
  327. // 初始化RTC
  328. aliRtcEngine.current = AliRtcEngine.getInstance();
  329. AliRtcEngine.setLogLevel(0);
  330. // 设置事件监听
  331. listenImEvents();
  332. listenRtcEvents();
  333. setIsLoggedIn(true);
  334. setErrorMessage('');
  335. showToast('success', '登录成功');
  336. // 登录成功,不生成分享链接(将在课堂创建成功后生成)
  337. } catch (err: any) {
  338. setErrorMessage(`登录失败: ${err.message}`);
  339. showToast('error', '登录失败');
  340. }
  341. };
  342. // 加入课堂
  343. const joinClass = async (classId: string): Promise<void> => {
  344. if (!imEngine.current || !aliRtcEngine.current) return;
  345. if (!classId) {
  346. setErrorMessage('课堂ID不能为空');
  347. showToast('error', '请输入有效的课堂ID');
  348. return;
  349. }
  350. try {
  351. // 加入IM群组
  352. const gm = imEngine.current.getGroupManager();
  353. const mm = imEngine.current.getMessageManager();
  354. imGroupManager.current = gm || null;
  355. imMessageManager.current = mm || null;
  356. await gm!.joinGroup(classId);
  357. listenGroupEvents();
  358. listenMessageEvents();
  359. // 加入RTC频道
  360. const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
  361. const token = await generateToken(RTC_APP_ID, RTC_APP_KEY, classId, userId, timestamp);
  362. aliRtcEngine.current.setChannelProfile(AliRtcSdkChannelProfile.AliRtcSdkCommunication);
  363. await aliRtcEngine.current.joinChannel(
  364. {
  365. channelId: classId,
  366. userId,
  367. appId: RTC_APP_ID,
  368. token,
  369. timestamp,
  370. },
  371. userId
  372. );
  373. // 设置本地预览
  374. aliRtcEngine.current.setLocalViewConfig('localPreviewer', AliRtcVideoTrack.AliRtcVideoTrackCamera);
  375. setIsJoinedClass(true);
  376. setErrorMessage('');
  377. showToast('success', '加入课堂成功');
  378. } catch (err: any) {
  379. setErrorMessage(`加入课堂失败: ${err.message}`);
  380. showToast('error', '加入课堂失败');
  381. }
  382. };
  383. // 离开课堂
  384. const leaveClass = async (): Promise<void> => {
  385. if (imGroupManager.current && classId) {
  386. await imGroupManager.current.leaveGroup(classId);
  387. }
  388. if (aliRtcEngine.current) {
  389. await aliRtcEngine.current.leaveChannel();
  390. }
  391. setIsJoinedClass(false);
  392. showToast('info', '已离开课堂');
  393. };
  394. // 发送消息
  395. const sendMessage = async (): Promise<void> => {
  396. if (!imMessageManager.current || !classId) return;
  397. try {
  398. await imMessageManager.current.sendGroupMessage({
  399. groupId: classId,
  400. data: msgText,
  401. type: 88888,
  402. level: ImMessageLevel.NORMAL,
  403. });
  404. setMsgText('');
  405. setErrorMessage('');
  406. } catch (err: any) {
  407. setErrorMessage(`消息发送失败: ${err.message}`);
  408. }
  409. };
  410. // 开始上课
  411. const startClass = async (): Promise<void> => {
  412. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  413. try {
  414. await imMessageManager.current.sendGroupMessage({
  415. groupId: classId,
  416. data: JSON.stringify({ action: 'start_class' }),
  417. type: 88889, // 自定义消息类型
  418. level: ImMessageLevel.HIGH,
  419. });
  420. setClassStatus(ClassStatus.IN_PROGRESS);
  421. showToast('success', '课堂已开始');
  422. } catch (err: any) {
  423. setErrorMessage(`开始上课失败: ${err.message}`);
  424. }
  425. };
  426. // 结束上课
  427. const endClass = async (): Promise<void> => {
  428. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  429. try {
  430. await imMessageManager.current.sendGroupMessage({
  431. groupId: classId,
  432. data: JSON.stringify({ action: 'end_class' }),
  433. type: 88889, // 自定义消息类型
  434. level: ImMessageLevel.HIGH,
  435. });
  436. setClassStatus(ClassStatus.ENDED);
  437. showToast('success', '课堂已结束');
  438. } catch (err: any) {
  439. setErrorMessage(`结束上课失败: ${err.message}`);
  440. }
  441. };
  442. // 静音/取消静音成员
  443. const toggleMuteMember = async (userId: string, mute: boolean): Promise<void> => {
  444. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  445. try {
  446. await imMessageManager.current.sendGroupMessage({
  447. groupId: classId,
  448. data: JSON.stringify({
  449. action: 'toggle_mute',
  450. userId,
  451. mute
  452. }),
  453. type: 88890, // 自定义消息类型
  454. level: ImMessageLevel.HIGH,
  455. });
  456. showToast('info', mute ? `已静音用户 ${userId}` : `已取消静音用户 ${userId}`);
  457. } catch (err: any) {
  458. setErrorMessage(`操作失败: ${err.message}`);
  459. }
  460. };
  461. // 创建课堂
  462. const createClass = async (className: string, maxMembers = 200): Promise<string | null> => {
  463. if (!imEngine.current || !isLoggedIn || role !== Role.Teacher) {
  464. showToast('error', '只有老师可以创建课堂');
  465. return null;
  466. }
  467. try {
  468. const groupManager = imEngine.current.getGroupManager();
  469. if (!groupManager) {
  470. throw new Error('群组管理器未初始化');
  471. }
  472. // 显示创建中状态
  473. showToast('info', '正在创建课堂...');
  474. // 调用IM SDK创建群组
  475. const response = await groupManager.createGroup({
  476. groupName: className,
  477. groupMeta: JSON.stringify({
  478. classType: 'interactive',
  479. creator: userId,
  480. createdAt: Date.now(),
  481. maxMembers
  482. })
  483. });
  484. if (!response?.groupId) {
  485. throw new Error('创建群组失败: 未返回群组ID');
  486. }
  487. // 创建成功后自动加入群组
  488. try {
  489. await groupManager.joinGroup(response.groupId);
  490. showToast('success', '课堂创建并加入成功');
  491. showMessage(`课堂 ${className} 创建成功,ID: ${response.groupId}`);
  492. // 更新状态
  493. setClassId(response.groupId);
  494. setIsJoinedClass(true);
  495. // 初始化群组消息管理器
  496. const messageManager = imEngine.current.getMessageManager();
  497. if (messageManager) {
  498. imMessageManager.current = messageManager;
  499. listenMessageEvents();
  500. }
  501. // 记录创建时间
  502. const createTime = new Date();
  503. showMessage(`创建时间: ${createTime.toLocaleString()}`);
  504. // 创建成功后生成分享链接
  505. setShareLink(`${window.location.href.split('?')[0]}?classId=${response.groupId}`);
  506. return response.groupId;
  507. } catch (joinErr: any) {
  508. throw new Error(`创建成功但加入失败: ${joinErr.message}`);
  509. }
  510. } catch (err: any) {
  511. const errorMsg = err.message.includes('alreadyExist')
  512. ? '课堂已存在'
  513. : `课堂创建失败: ${err.message}`;
  514. setErrorMessage(errorMsg);
  515. showToast('error', errorMsg);
  516. return null;
  517. }
  518. };
  519. // 清理资源
  520. useEffect(() => {
  521. return () => {
  522. if (imGroupManager.current) {
  523. imGroupManager.current.removeAllListeners();
  524. }
  525. if (imMessageManager.current) {
  526. imMessageManager.current.removeAllListeners();
  527. }
  528. if (imEngine.current) {
  529. imEngine.current.removeAllListeners();
  530. }
  531. if (aliRtcEngine.current) {
  532. aliRtcEngine.current.destroy();
  533. }
  534. };
  535. }, []);
  536. // 学生举手
  537. const handUp = async (question?: string): Promise<void> => {
  538. if (!imMessageManager.current || !classId || role !== 'student') return;
  539. try {
  540. await imMessageManager.current.sendGroupMessage({
  541. groupId: classId,
  542. data: JSON.stringify({
  543. action: 'hand_up',
  544. studentId: userId,
  545. timestamp: Date.now(),
  546. question
  547. }),
  548. type: 88891,
  549. level: ImMessageLevel.NORMAL,
  550. });
  551. } catch (err: any) {
  552. setErrorMessage(`举手失败: ${err.message}`);
  553. }
  554. };
  555. // 老师应答举手
  556. const answerHandUp = async (studentId: string): Promise<void> => {
  557. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  558. try {
  559. await imMessageManager.current.sendGroupMessage({
  560. groupId: classId,
  561. data: JSON.stringify({
  562. action: 'answer_hand_up',
  563. studentId
  564. }),
  565. type: 88893,
  566. level: ImMessageLevel.HIGH,
  567. });
  568. } catch (err: any) {
  569. setErrorMessage(`应答失败: ${err.message}`);
  570. }
  571. };
  572. // 发送问题
  573. const sendQuestion = async (question: string): Promise<void> => {
  574. if (!imMessageManager.current || !classId) return;
  575. try {
  576. await imMessageManager.current.sendGroupMessage({
  577. groupId: classId,
  578. data: question,
  579. type: 88892,
  580. level: ImMessageLevel.NORMAL,
  581. });
  582. } catch (err: any) {
  583. setErrorMessage(`问题发送失败: ${err.message}`);
  584. }
  585. };
  586. return (
  587. <ClassroomContext.Provider value={{
  588. userId,
  589. role,
  590. isLoggedIn,
  591. isJoinedClass,
  592. messageList,
  593. errorMessage,
  594. classStatus,
  595. handUpList,
  596. questions,
  597. setRole: (role: Role) => setRole(role as Role),
  598. createClass,
  599. startClass,
  600. endClass,
  601. toggleMuteMember,
  602. handUp,
  603. answerHandUp,
  604. sendQuestion
  605. }}>
  606. <div className="container mx-auto p-4">
  607. <h1 className="text-2xl font-bold mb-4">互动课堂</h1>
  608. <ToastContainer
  609. position="top-right"
  610. autoClose={5000}
  611. hideProgressBar={false}
  612. newestOnTop={false}
  613. closeOnClick
  614. rtl={false}
  615. pauseOnFocusLoss
  616. draggable
  617. pauseOnHover
  618. />
  619. <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
  620. <div className="md:col-span-1">
  621. {shareLink && (
  622. <div className="mb-4 p-4 bg-white rounded-md shadow">
  623. <h4 className="text-lg font-medium mb-2">课堂分享链接</h4>
  624. <div className="flex items-center">
  625. <input
  626. type="text"
  627. readOnly
  628. value={shareLink}
  629. className="flex-1 px-3 py-2 border border-gray-300 rounded-l-md"
  630. />
  631. <button
  632. type="button"
  633. className="px-3 py-2 bg-blue-600 text-white rounded-r-md"
  634. onClick={() => {
  635. navigator.clipboard.writeText(shareLink);
  636. showToast('info', '链接已复制');
  637. }}
  638. >
  639. 复制
  640. </button>
  641. </div>
  642. </div>
  643. )}
  644. <form>
  645. {!isLoggedIn && (
  646. <div className="mb-2">
  647. <label className="block text-sm font-medium text-gray-700">课堂名称</label>
  648. <input
  649. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  650. value={className}
  651. onChange={(e) => setClassName(e.target.value)}
  652. placeholder="输入课堂名称"
  653. />
  654. </div>
  655. )}
  656. <div className="mb-2">
  657. <label className="block text-sm font-medium text-gray-700">用户ID</label>
  658. <input
  659. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  660. value={userId}
  661. onChange={(e) => setUserId(e.target.value)}
  662. />
  663. </div>
  664. <div className="mb-2">
  665. <label className="block text-sm font-medium text-gray-700">课堂ID</label>
  666. <input
  667. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  668. value={classId}
  669. onChange={(e) => setClassId(e.target.value)}
  670. />
  671. </div>
  672. <div className="mb-2">
  673. <label className="block text-sm font-medium text-gray-700">角色</label>
  674. <select
  675. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  676. value={role}
  677. onChange={(e) => setRole(e.target.value as Role)}
  678. >
  679. <option value={Role.Student}>学生</option>
  680. <option value={Role.Teacher}>老师</option>
  681. </select>
  682. </div>
  683. <div className="flex space-x-2 mb-2">
  684. {!isLoggedIn && (
  685. <button
  686. type="button"
  687. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  688. onClick={() => login(userId)}
  689. >
  690. 登录
  691. </button>
  692. )}
  693. {isLoggedIn && role === Role.Teacher && (
  694. <button
  695. type="button"
  696. className="px-3 py-2 bg-green-600 text-white rounded-md"
  697. disabled={!className}
  698. onClick={async () => {
  699. const classId = await createClass(className);
  700. if (classId) {
  701. setClassId(classId);
  702. }
  703. }}
  704. >
  705. 创建课堂
  706. </button>
  707. )}
  708. <button
  709. type="button"
  710. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  711. disabled={!isLoggedIn || isJoinedClass}
  712. onClick={() => joinClass(classId)}
  713. >
  714. 加入课堂
  715. </button>
  716. <button
  717. type="button"
  718. className="px-3 py-2 bg-gray-600 text-white rounded-md"
  719. disabled={!isJoinedClass}
  720. onClick={leaveClass}
  721. >
  722. 离开课堂
  723. </button>
  724. </div>
  725. </form>
  726. <div className="mt-4">
  727. <label className="block text-sm font-medium text-gray-700">消息</label>
  728. <input
  729. className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
  730. value={msgText}
  731. onChange={(e) => setMsgText(e.target.value)}
  732. />
  733. <button
  734. type="button"
  735. className="mt-2 px-3 py-2 bg-blue-600 text-white rounded-md"
  736. disabled={!isJoinedClass}
  737. onClick={sendMessage}
  738. >
  739. 发送
  740. </button>
  741. </div>
  742. {role === 'student' && isJoinedClass && (
  743. <div className="mt-4 p-4 bg-white rounded-md shadow">
  744. <h4 className="text-lg font-medium mb-2">互动功能</h4>
  745. <div className="space-y-3">
  746. <button
  747. type="button"
  748. className="w-full px-3 py-2 bg-green-600 text-white rounded-md"
  749. onClick={() => handUp()}
  750. >
  751. 举手
  752. </button>
  753. <div className="flex space-x-2">
  754. <input
  755. type="text"
  756. placeholder="输入问题..."
  757. className="flex-1 px-3 py-2 border border-gray-300 rounded-md"
  758. id="questionInput"
  759. />
  760. <button
  761. type="button"
  762. className="px-3 py-2 bg-blue-600 text-white rounded-md"
  763. onClick={() => {
  764. const input = document.getElementById('questionInput') as HTMLInputElement;
  765. if (input.value) {
  766. sendQuestion(input.value);
  767. input.value = '';
  768. }
  769. }}
  770. >
  771. 提问
  772. </button>
  773. </div>
  774. </div>
  775. </div>
  776. )}
  777. {role === Role.Teacher && handUpList.length > 0 && (
  778. <div className="mt-4 p-4 bg-white rounded-md shadow">
  779. <h4 className="text-lg font-medium mb-2">举手列表 ({handUpList.length})</h4>
  780. <div className="space-y-2">
  781. {handUpList.map((req, i) => (
  782. <div key={i} className="flex items-center justify-between p-2 border-b">
  783. <div>
  784. <div className="font-medium">{req.studentName || req.studentId}</div>
  785. {req.question && <div className="text-sm text-gray-500">{req.question}</div>}
  786. </div>
  787. <button
  788. type="button"
  789. className="px-2 py-1 bg-blue-600 text-white rounded text-sm"
  790. onClick={() => answerHandUp(req.studentId)}
  791. >
  792. 应答
  793. </button>
  794. </div>
  795. ))}
  796. </div>
  797. </div>
  798. )}
  799. {questions.length > 0 && (
  800. <div className="mt-4 p-4 bg-white rounded-md shadow">
  801. <h4 className="text-lg font-medium mb-2">问题列表 ({questions.length})</h4>
  802. <div className="space-y-2">
  803. {questions.map((q, i) => (
  804. <div key={i} className="p-2 border-b">
  805. <div className="font-medium">问题 {i + 1}</div>
  806. <div className="text-gray-700">{q}</div>
  807. </div>
  808. ))}
  809. </div>
  810. </div>
  811. )}
  812. </div>
  813. <div className="md:col-span-1">
  814. <h4 className="text-lg font-medium mb-2">消息记录</h4>
  815. <div className="bg-gray-100 p-2 rounded-md h-64 overflow-y-auto">
  816. {messageList.map((msg, i) => (
  817. <div key={i} className="mb-1">{msg}</div>
  818. ))}
  819. </div>
  820. {role === Role.Teacher && isJoinedClass && (
  821. <div className="mt-4 p-4 bg-white rounded-md shadow">
  822. <h4 className="text-lg font-medium mb-2">老师控制面板</h4>
  823. <div className="flex space-x-2 mb-4">
  824. <button
  825. type="button"
  826. className="px-3 py-2 bg-green-600 text-white rounded-md"
  827. disabled={classStatus === ClassStatus.IN_PROGRESS}
  828. onClick={startClass}
  829. >
  830. 开始上课
  831. </button>
  832. <button
  833. type="button"
  834. className="px-3 py-2 bg-red-600 text-white rounded-md"
  835. disabled={classStatus !== ClassStatus.IN_PROGRESS}
  836. onClick={endClass}
  837. >
  838. 结束上课
  839. </button>
  840. </div>
  841. <div>
  842. <h5 className="font-medium mb-2">成员管理</h5>
  843. <div className="space-y-2">
  844. {students.map(student => (
  845. <div key={student.id} className="flex items-center justify-between">
  846. <span>{student.name}</span>
  847. <div className="space-x-2">
  848. <button
  849. type="button"
  850. className="px-2 py-1 bg-yellow-500 text-white rounded text-sm"
  851. onClick={() => toggleMuteMember(student.id, true)}
  852. >
  853. 静音
  854. </button>
  855. <button
  856. type="button"
  857. className="px-2 py-1 bg-blue-500 text-white rounded text-sm"
  858. onClick={() => toggleMuteMember(student.id, false)}
  859. >
  860. 取消静音
  861. </button>
  862. </div>
  863. </div>
  864. ))}
  865. </div>
  866. </div>
  867. </div>
  868. )}
  869. </div>
  870. <div className="md:col-span-1">
  871. <h4 className="text-lg font-medium mb-2">视频区域</h4>
  872. <video
  873. id="localPreviewer"
  874. muted
  875. className="w-full h-48 bg-black mb-2"
  876. ></video>
  877. <div
  878. id="remoteVideoContainer"
  879. ref={remoteVideoContainer}
  880. className="grid grid-cols-2 gap-2"
  881. ></div>
  882. </div>
  883. </div>
  884. {errorMessage && (
  885. <div className="mt-2 text-red-500">{errorMessage}</div>
  886. )}
  887. </div>
  888. </ClassroomContext.Provider>
  889. );
  890. };