pages_classroom.tsx 30 KB

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