pages_classroom.tsx 30 KB

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