pages_classroom.tsx 33 KB

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