|
|
@@ -1,1292 +0,0 @@
|
|
|
-import React, { useState, useEffect, useRef, createContext, useContext } from 'react';
|
|
|
-// @ts-types="../share/aliyun-rtc-sdk.d.ts"
|
|
|
-import AliRtcEngine, { AliRtcSubscribeState, AliRtcVideoTrack, AliRtcSdkChannelProfile } from 'aliyun-rtc-sdk';
|
|
|
-// import AliRtcEngine, { AliRtcSubscribeState, AliRtcVideoTrack, AliRtcSdkChannelProfile } from '../share/aliyun-rtc-sdk.js';
|
|
|
-import { ToastContainer, toast } from 'react-toastify';
|
|
|
-
|
|
|
-// 从 SDK 中提取需要的类型
|
|
|
-type ImEngine = InstanceType<typeof AliVCInteraction.ImEngine>;
|
|
|
-type ImGroupManager = AliVCInteraction.AliVCIMGroupManager;
|
|
|
-type ImMessageManager = AliVCInteraction.AliVCIMMessageManager;
|
|
|
-type ImLogLevel = AliVCInteraction.ImLogLevel;
|
|
|
-type ImMessageLevel = AliVCInteraction.ImMessageLevel;
|
|
|
-
|
|
|
-interface ImUser {
|
|
|
- userId: string;
|
|
|
- userExtension?: string;
|
|
|
-}
|
|
|
-
|
|
|
-interface ImGroupMessage {
|
|
|
- groupId: string;
|
|
|
- type: number;
|
|
|
- data: string;
|
|
|
- sender?: ImUser;
|
|
|
- timestamp?: number;
|
|
|
-}
|
|
|
-
|
|
|
-enum Role {
|
|
|
- Teacher = 'admin',
|
|
|
- Student = 'student'
|
|
|
-}
|
|
|
-
|
|
|
-// 课堂状态枚举
|
|
|
-enum ClassStatus {
|
|
|
- NOT_STARTED = 'not_started',
|
|
|
- IN_PROGRESS = 'in_progress',
|
|
|
- ENDED = 'ended'
|
|
|
-}
|
|
|
-
|
|
|
-// 课堂上下文类型
|
|
|
-// 互动消息类型
|
|
|
-type InteractionAction = 'hand_up' | 'cancel_hand_up' | 'answer_hand_up';
|
|
|
-
|
|
|
-// 互动消息基础接口
|
|
|
-interface InteractionMessage {
|
|
|
- action: InteractionAction;
|
|
|
- studentId: string;
|
|
|
- studentName?: string;
|
|
|
- timestamp?: number;
|
|
|
- question?: string;
|
|
|
-}
|
|
|
-
|
|
|
-// 举手请求类型
|
|
|
-interface HandUpRequest extends InteractionMessage {
|
|
|
- timestamp: number;
|
|
|
-}
|
|
|
-
|
|
|
-type ClassroomContextType = {
|
|
|
- userId: string;
|
|
|
- role: Role;
|
|
|
- isLoggedIn: boolean;
|
|
|
- isJoinedClass: boolean;
|
|
|
- messageList: string[];
|
|
|
- errorMessage: string;
|
|
|
- classStatus: ClassStatus;
|
|
|
- handUpList: HandUpRequest[]; // 举手列表
|
|
|
- questions: string[]; // 问题列表
|
|
|
- setRole: (role: Role) => void;
|
|
|
- createClass: (className: string, maxMembers?: number) => Promise<string | null>; // 创建课堂
|
|
|
- startClass: () => Promise<void>;
|
|
|
- endClass: () => Promise<void>;
|
|
|
- toggleMuteMember: (userId: string, mute: boolean) => Promise<void>;
|
|
|
- handUp: (question?: string) => Promise<void>; // 学生举手
|
|
|
- answerHandUp: (studentId: string) => Promise<void>; // 老师应答
|
|
|
- sendQuestion: (question: string) => Promise<void>; // 发送问题
|
|
|
-};
|
|
|
-
|
|
|
-const ClassroomContext = createContext<ClassroomContextType | null>(null);
|
|
|
-
|
|
|
-// 辅助函数
|
|
|
-function hex(buffer: ArrayBuffer): string {
|
|
|
- const hexCodes = [];
|
|
|
- const view = new DataView(buffer);
|
|
|
- for (let i = 0; i < view.byteLength; i += 4) {
|
|
|
- const value = view.getUint32(i);
|
|
|
- const stringValue = value.toString(16);
|
|
|
- const padding = '00000000';
|
|
|
- const paddedValue = (padding + stringValue).slice(-padding.length);
|
|
|
- hexCodes.push(paddedValue);
|
|
|
- }
|
|
|
- return hexCodes.join('');
|
|
|
-}
|
|
|
-
|
|
|
-async function generateToken(
|
|
|
- appId: string,
|
|
|
- appKey: string,
|
|
|
- channelId: string,
|
|
|
- userId: string,
|
|
|
- timestamp: number
|
|
|
-): Promise<string> {
|
|
|
- const encoder = new TextEncoder();
|
|
|
- const data = encoder.encode(`${appId}${appKey}${channelId}${userId}${timestamp}`);
|
|
|
- const hash = await crypto.subtle.digest('SHA-256', data);
|
|
|
- return hex(hash);
|
|
|
-}
|
|
|
-
|
|
|
-function showToast(type: 'info' | 'success' | 'error', message: string): void {
|
|
|
- switch(type) {
|
|
|
- case 'info':
|
|
|
- toast.info(message);
|
|
|
- break;
|
|
|
- case 'success':
|
|
|
- toast.success(message);
|
|
|
- break;
|
|
|
- case 'error':
|
|
|
- toast.error(message);
|
|
|
- break;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// 从SDK获取枚举值
|
|
|
-const { ImLogLevel, ImMessageLevel } = window.AliVCInteraction;
|
|
|
-
|
|
|
-// 配置信息
|
|
|
-const IM_APP_ID = '4c2ab5e1b1b0';
|
|
|
-const IM_APP_KEY = '314bb5eee5b623549e8a41574ba3ff32';
|
|
|
-const IM_APP_SIGN = 'H4sIAAAAAAAE/wCQAG//zguHB+lYCilkv7diSkk4GmcvLuds+InRu9vFOFebMwm/jEgsK5bBT85Z0owObMxG58uXHyPFlPEBEDQm9FswNJ+KmX0VDYkcfdPPWkafA6Hc0B6F+p5De9yJfPEfHzwo/DHMaygbHfLmBgUtmKveq421sJr/gNBz9D04Ewsg39us+ao0NegzLt7xtXvFXXXJAAAA//8BAAD//yoav6aQAAAA';
|
|
|
-const RTC_APP_ID = 'a5842c2a-d94a-43be-81de-1fdb712476e1';
|
|
|
-const RTC_APP_KEY = 'b71d65f4f84c450f6f058f4ad507bd42';
|
|
|
-
|
|
|
-// IM Token生成
|
|
|
-async function generateImToken(userId: string, role: string): Promise<string> {
|
|
|
- const nonce = 'AK_4';
|
|
|
- const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
|
|
|
- const pendingShaStr = `${IM_APP_ID}${IM_APP_KEY}${userId}${nonce}${timestamp}${role}`;
|
|
|
- const encoder = new TextEncoder();
|
|
|
- const data = encoder.encode(pendingShaStr);
|
|
|
- const hash = await crypto.subtle.digest('SHA-256', data);
|
|
|
- return hex(hash);
|
|
|
-}
|
|
|
-
|
|
|
-export const ClassroomPage = () => {
|
|
|
- // 解析URL参数
|
|
|
- useEffect(() => {
|
|
|
- const queryParams = new URLSearchParams(window.location.search);
|
|
|
- const urlClassId = queryParams.get('classId');
|
|
|
- if (urlClassId) {
|
|
|
- setClassId(urlClassId);
|
|
|
- showMessage(`从分享链接获取课堂ID: ${urlClassId}`);
|
|
|
- }
|
|
|
- }, []);
|
|
|
-
|
|
|
- // 状态管理
|
|
|
- const [userId, setUserId] = useState<string>('');
|
|
|
- const [isCameraOn, setIsCameraOn] = useState<boolean>(false);
|
|
|
- const [isAudioOn, setIsAudioOn] = useState<boolean>(false);
|
|
|
- const [isScreenSharing, setIsScreenSharing] = useState<boolean>(false);
|
|
|
- const [className, setClassName] = useState<string>('');
|
|
|
- const [role, setRole] = useState<Role>(Role.Student);
|
|
|
- const [classId, setClassId] = useState<string>('');
|
|
|
- const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
|
|
|
- const [isJoinedClass, setIsJoinedClass] = useState<boolean>(false);
|
|
|
- const [msgText, setMsgText] = useState<string>('');
|
|
|
- const [messageList, setMessageList] = useState<string[]>([]);
|
|
|
- const [errorMessage, setErrorMessage] = useState<string>('');
|
|
|
- const [classStatus, setClassStatus] = useState<ClassStatus>(ClassStatus.NOT_STARTED);
|
|
|
- const [handUpList, setHandUpList] = useState<HandUpRequest[]>([]);
|
|
|
- const [questions, setQuestions] = useState<string[]>([]);
|
|
|
- const [students, setStudents] = useState<Array<{id: string, name: string}>>([]);
|
|
|
- const [shareLink, setShareLink] = useState<string>('');
|
|
|
-
|
|
|
- // SDK实例
|
|
|
- const imEngine = useRef<ImEngine | null>(null);
|
|
|
- const imGroupManager = useRef<ImGroupManager | null>(null);
|
|
|
- const imMessageManager = useRef<ImMessageManager | null>(null);
|
|
|
- const aliRtcEngine = useRef<AliRtcEngine | null>(null);
|
|
|
- const remoteVideoElMap = useRef<Record<string, HTMLVideoElement>>({});
|
|
|
- const remoteVideoContainer = useRef<HTMLDivElement>(null);
|
|
|
-
|
|
|
- // 消息管理模块
|
|
|
- const showMessage = (text: string): void => {
|
|
|
- setMessageList((prevMessageList) => [...prevMessageList, text])
|
|
|
- };
|
|
|
-
|
|
|
- const listenImEvents = (): void => {
|
|
|
- if (!imEngine.current) return;
|
|
|
-
|
|
|
- imEngine.current.on('connectsuccess', () => {
|
|
|
- showMessage('IM连接成功');
|
|
|
- });
|
|
|
-
|
|
|
- imEngine.current.on('disconnect', async (code: number) => {
|
|
|
- showMessage(`IM断开连接: ${code}`);
|
|
|
- // 自动重连
|
|
|
- try {
|
|
|
- const imToken = await generateImToken(userId, role);
|
|
|
- await imEngine.current!.login({
|
|
|
- user: {
|
|
|
- userId,
|
|
|
- userExtension: '{}'
|
|
|
- },
|
|
|
- userAuth: {
|
|
|
- nonce: 'AK_4',
|
|
|
- timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
|
|
|
- token: imToken,
|
|
|
- role
|
|
|
- }
|
|
|
- });
|
|
|
- showMessage('IM自动重连成功');
|
|
|
- } catch (err: unknown) {
|
|
|
- const error = err as Error;
|
|
|
- showMessage(`IM自动重连失败: ${error.message}`);
|
|
|
- }
|
|
|
- });
|
|
|
- };
|
|
|
-
|
|
|
- const listenGroupEvents = (): void => {
|
|
|
- if (!imGroupManager.current) return;
|
|
|
-
|
|
|
- imGroupManager.current.on('memberchange', (groupId: string, memberCount: number, joinUsers: ImUser[], leaveUsers: ImUser[]) => {
|
|
|
- showMessage(`成员变更: 加入${joinUsers.length}人, 离开${leaveUsers.length}人`);
|
|
|
- });
|
|
|
- };
|
|
|
-
|
|
|
- const listenMessageEvents = (): void => {
|
|
|
- if (!imMessageManager.current) return;
|
|
|
-
|
|
|
- imMessageManager.current.on('recvgroupmessage', (msg: AliVCInteraction.ImMessage, groupId: string) => {
|
|
|
- if (msg.type === 88889) { // 课堂状态消息
|
|
|
- try {
|
|
|
- const data = JSON.parse(msg.data);
|
|
|
- if (data.action === 'start_class') {
|
|
|
- setClassStatus(ClassStatus.IN_PROGRESS);
|
|
|
- showMessage('老师已开始上课');
|
|
|
- } else if (data.action === 'end_class') {
|
|
|
- setClassStatus(ClassStatus.ENDED);
|
|
|
- showMessage('老师已结束上课');
|
|
|
- }
|
|
|
- } catch (err) {
|
|
|
- console.error('解析课堂状态消息失败', err);
|
|
|
- }
|
|
|
- } else if (msg.type === 88890) { // 静音指令
|
|
|
- try {
|
|
|
- const data = JSON.parse(msg.data);
|
|
|
- if (data.action === 'toggle_mute' && data.userId === userId) {
|
|
|
- showMessage(data.mute ? '你已被老师静音' : '老师已取消你的静音');
|
|
|
- }
|
|
|
- } catch (err) {
|
|
|
- console.error('解析静音指令失败', err);
|
|
|
- }
|
|
|
- } else if (msg.type === 88891) { // 举手消息
|
|
|
- try {
|
|
|
- const data = JSON.parse(msg.data) as InteractionMessage;
|
|
|
- if (data.action === 'hand_up') {
|
|
|
- const handUpData: HandUpRequest = {
|
|
|
- ...data,
|
|
|
- timestamp: data.timestamp || Date.now()
|
|
|
- };
|
|
|
- setHandUpList([...handUpList, handUpData]);
|
|
|
- showMessage(`${data.studentName || data.studentId} 举手了`);
|
|
|
- } else if (data.action === 'cancel_hand_up') {
|
|
|
- setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
|
|
|
- }
|
|
|
- } catch (err) {
|
|
|
- console.error('解析举手消息失败', err);
|
|
|
- }
|
|
|
- } else if (msg.type === 88892) { // 问题消息
|
|
|
- try {
|
|
|
- const data = JSON.parse(msg.data) as {question: string};
|
|
|
- setQuestions([...questions, data.question]);
|
|
|
- showMessage(`收到问题: ${data.question}`);
|
|
|
- } catch (err) {
|
|
|
- console.error('解析问题消息失败', err);
|
|
|
- }
|
|
|
- } else if (msg.type === 88893) { // 应答消息
|
|
|
- try {
|
|
|
- const data = JSON.parse(msg.data) as InteractionMessage;
|
|
|
- if (data.action === 'answer_hand_up' && data.studentId === userId) {
|
|
|
- showMessage('老师已应答你的举手');
|
|
|
- setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
|
|
|
- }
|
|
|
- } catch (err) {
|
|
|
- console.error('解析应答消息失败', err);
|
|
|
- }
|
|
|
- } else if (msg.type === 88888) { // 普通文本消息
|
|
|
- showMessage(`${msg.sender?.userId || '未知用户'}: ${msg.data}`);
|
|
|
- }
|
|
|
- });
|
|
|
- };
|
|
|
-
|
|
|
- // 音视频模块
|
|
|
- const removeRemoteVideo = (userId: string, type: 'camera' | 'screen' = 'camera') => {
|
|
|
- const vid = `${type}_${userId}`;
|
|
|
- const el = remoteVideoElMap.current[vid];
|
|
|
- if (el) {
|
|
|
- aliRtcEngine.current!.setRemoteViewConfig(null, userId, type === 'camera' ? AliRtcVideoTrack.AliRtcVideoTrackCamera : AliRtcVideoTrack.AliRtcVideoTrackScreen);
|
|
|
- el.pause();
|
|
|
- remoteVideoContainer.current?.removeChild(el);
|
|
|
- delete remoteVideoElMap.current[vid];
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- const listenRtcEvents = () => {
|
|
|
- if (!aliRtcEngine.current) return;
|
|
|
-
|
|
|
- showMessage('注册rtc事件监听')
|
|
|
-
|
|
|
- aliRtcEngine.current.on('remoteUserOnLineNotify', (userId: string) => {
|
|
|
- showMessage(`用户 ${userId} 加入课堂`);
|
|
|
- console.log('用户上线通知:', userId);
|
|
|
- });
|
|
|
-
|
|
|
- aliRtcEngine.current.on('remoteUserOffLineNotify', (userId: string) => {
|
|
|
- showMessage(`用户 ${userId} 离开课堂`);
|
|
|
- console.log('用户下线通知:', userId);
|
|
|
- removeRemoteVideo(userId, 'camera');
|
|
|
- removeRemoteVideo(userId, 'screen');
|
|
|
- });
|
|
|
-
|
|
|
- // 订阅所有用户视频流
|
|
|
- aliRtcEngine.current.on('videoSubscribeStateChanged', (
|
|
|
- userId: string,
|
|
|
- oldState: AliRtcSubscribeState,
|
|
|
- newState: AliRtcSubscribeState,
|
|
|
- interval: number,
|
|
|
- channelId: string
|
|
|
- ) => {
|
|
|
- console.log(`视频订阅状态变化: 用户 ${userId}, 旧状态 ${oldState}, 新状态 ${newState}`);
|
|
|
-
|
|
|
- switch(newState) {
|
|
|
- case 3: // 订阅成功
|
|
|
- try {
|
|
|
- console.log('开始创建远程视频元素');
|
|
|
-
|
|
|
- // 检查是否已有该用户的视频元素
|
|
|
- if (remoteVideoElMap.current[`camera_${userId}`]) {
|
|
|
- console.log(`用户 ${userId} 的视频元素已存在`);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const video = document.createElement('video');
|
|
|
- video.autoplay = true;
|
|
|
- video.playsInline = true;
|
|
|
- video.className = 'w-80 h-45 mr-2 mb-2 bg-black';
|
|
|
-
|
|
|
- if (!remoteVideoContainer.current) {
|
|
|
- console.error('远程视频容器未找到');
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 确保容器可见
|
|
|
- remoteVideoContainer.current.style.display = 'block';
|
|
|
- remoteVideoContainer.current.appendChild(video);
|
|
|
- remoteVideoElMap.current[`camera_${userId}`] = video;
|
|
|
-
|
|
|
- // 设置远程视图配置
|
|
|
- aliRtcEngine.current!.setRemoteViewConfig(
|
|
|
- video,
|
|
|
- userId,
|
|
|
- AliRtcVideoTrack.AliRtcVideoTrackCamera
|
|
|
- );
|
|
|
-
|
|
|
- console.log(`已订阅用户 ${userId} 的视频流`);
|
|
|
- showMessage(`已显示用户 ${userId} 的视频`);
|
|
|
- } catch (err) {
|
|
|
- console.error(`订阅用户 ${userId} 视频流失败:`, err);
|
|
|
- showMessage(`订阅用户 ${userId} 视频流失败`);
|
|
|
- }
|
|
|
- break;
|
|
|
-
|
|
|
- case 1: // 取消订阅
|
|
|
- console.log(`取消订阅用户 ${userId} 的视频流`);
|
|
|
- removeRemoteVideo(userId, 'camera');
|
|
|
- break;
|
|
|
-
|
|
|
- case 2: // 订阅中
|
|
|
- console.log(`正在订阅用户 ${userId} 的视频流...`);
|
|
|
- break;
|
|
|
-
|
|
|
- default:
|
|
|
- console.warn(`未知订阅状态: ${newState}`);
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
-
|
|
|
- // 订阅屏幕分享流状态变化
|
|
|
- aliRtcEngine.current.on('screenShareSubscribeStateChanged', (
|
|
|
- userId: string,
|
|
|
- oldState: AliRtcSubscribeState,
|
|
|
- newState: AliRtcSubscribeState,
|
|
|
- elapseSinceLastState: number,
|
|
|
- channel: string
|
|
|
- ) => {
|
|
|
- console.log(`屏幕分享订阅状态变更:uid=${userId}, oldState=${oldState}, newState=${newState}`);
|
|
|
-
|
|
|
- switch(newState) {
|
|
|
- case 3: // 订阅成功
|
|
|
- try {
|
|
|
- console.log('开始创建屏幕分享视频元素');
|
|
|
-
|
|
|
- // 检查是否已有该用户的屏幕分享元素
|
|
|
- if (remoteVideoElMap.current[`screen_${userId}`]) {
|
|
|
- console.log(`用户 ${userId} 的屏幕分享元素已存在`);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const video = document.createElement('video');
|
|
|
- video.autoplay = true;
|
|
|
- video.playsInline = true;
|
|
|
- video.className = 'w-80 h-45 mr-2 mb-2 bg-black';
|
|
|
-
|
|
|
- if (!remoteVideoContainer.current) {
|
|
|
- console.error('远程视频容器未找到');
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- remoteVideoContainer.current.appendChild(video);
|
|
|
- remoteVideoElMap.current[`screen_${userId}`] = video;
|
|
|
-
|
|
|
- // 设置远程视图配置
|
|
|
- aliRtcEngine.current!.setRemoteViewConfig(
|
|
|
- video,
|
|
|
- userId,
|
|
|
- AliRtcVideoTrack.AliRtcVideoTrackScreen
|
|
|
- );
|
|
|
-
|
|
|
- console.log(`已订阅用户 ${userId} 的屏幕分享流`);
|
|
|
- showMessage(`已显示用户 ${userId} 的屏幕分享`);
|
|
|
- } catch (err) {
|
|
|
- console.error(`订阅用户 ${userId} 屏幕分享流失败:`, err);
|
|
|
- showMessage(`订阅用户 ${userId} 屏幕分享流失败`);
|
|
|
- }
|
|
|
- break;
|
|
|
-
|
|
|
- case 1: // 取消订阅
|
|
|
- console.log(`取消订阅用户 ${userId} 的屏幕分享流`);
|
|
|
- removeRemoteVideo(userId, 'screen');
|
|
|
- break;
|
|
|
-
|
|
|
- case 2: // 订阅中
|
|
|
- console.log(`正在订阅用户 ${userId} 的屏幕分享流...`);
|
|
|
- break;
|
|
|
-
|
|
|
- default:
|
|
|
- console.warn(`未知屏幕分享订阅状态: ${newState}`);
|
|
|
- }
|
|
|
- });
|
|
|
- };
|
|
|
-
|
|
|
- // 获取学生列表
|
|
|
- const fetchStudents = async (classId: string) => {
|
|
|
- try {
|
|
|
- if (!imEngine.current) {
|
|
|
- throw new Error('IM引擎未初始化');
|
|
|
- }
|
|
|
-
|
|
|
- const groupManager = imEngine.current.getGroupManager();
|
|
|
- if (!groupManager) {
|
|
|
- throw new Error('IM群组管理器未初始化');
|
|
|
- }
|
|
|
-
|
|
|
- // 使用classId作为群组ID获取成员
|
|
|
- const response = await groupManager.listRecentGroupUser(classId);
|
|
|
-
|
|
|
- // 转换IM用户数据格式
|
|
|
- const students = response.userList.map((user: ImUser) => ({
|
|
|
- id: user.userId,
|
|
|
- name: user.userExtension || `用户${user.userId}`
|
|
|
- }));
|
|
|
-
|
|
|
- setStudents(students);
|
|
|
- } catch (err) {
|
|
|
- console.error('从IM获取学生列表失败:', err);
|
|
|
- // 可选: 显示错误提示给用户
|
|
|
- // setError('获取学生列表失败,请稍后重试');
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // 统一登录逻辑
|
|
|
- const login = async (userId: string): Promise<void> => {
|
|
|
- try {
|
|
|
- // 初始化IM
|
|
|
- const { ImEngine: ImEngineClass } = window.AliVCInteraction;
|
|
|
- imEngine.current = ImEngineClass.createEngine();
|
|
|
- await imEngine.current.init({
|
|
|
- deviceId: 'xxxx',
|
|
|
- appId: IM_APP_ID,
|
|
|
- appSign: IM_APP_SIGN,
|
|
|
- logLevel: ImLogLevel.ERROR,
|
|
|
- });
|
|
|
-
|
|
|
- // 登录IM
|
|
|
- const imToken = await generateImToken(userId, role);
|
|
|
- await imEngine.current.login({
|
|
|
- user: {
|
|
|
- userId,
|
|
|
- userExtension: '{}'
|
|
|
- },
|
|
|
- userAuth: {
|
|
|
- nonce: 'AK_4',
|
|
|
- timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
|
|
|
- token: imToken,
|
|
|
- role
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- // 初始化RTC
|
|
|
- aliRtcEngine.current = AliRtcEngine.getInstance();
|
|
|
- AliRtcEngine.setLogLevel(0);
|
|
|
- // 设置事件监听
|
|
|
- listenImEvents();
|
|
|
- listenRtcEvents();
|
|
|
-
|
|
|
- setIsLoggedIn(true);
|
|
|
- setErrorMessage('');
|
|
|
- showToast('success', '登录成功');
|
|
|
-
|
|
|
- // 登录成功,不生成分享链接(将在课堂创建成功后生成)
|
|
|
- } catch (err: any) {
|
|
|
- setErrorMessage(`登录失败: ${err.message}`);
|
|
|
- showToast('error', '登录失败');
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // 加入课堂
|
|
|
- const joinClass = async (classId: string): Promise<void> => {
|
|
|
- if (!imEngine.current || !aliRtcEngine.current) return;
|
|
|
-
|
|
|
- if (!classId) {
|
|
|
- setErrorMessage('课堂ID不能为空');
|
|
|
- showToast('error', '请输入有效的课堂ID');
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- // 加入IM群组
|
|
|
- const gm = imEngine.current.getGroupManager();
|
|
|
- const mm = imEngine.current.getMessageManager();
|
|
|
- imGroupManager.current = gm || null;
|
|
|
- imMessageManager.current = mm || null;
|
|
|
- await gm!.joinGroup(classId);
|
|
|
- listenGroupEvents();
|
|
|
- listenMessageEvents();
|
|
|
-
|
|
|
- // 加入RTC频道
|
|
|
- await joinRtcChannel(classId);
|
|
|
-
|
|
|
- setIsJoinedClass(true);
|
|
|
- setErrorMessage('');
|
|
|
- showToast('success', '加入课堂成功');
|
|
|
- } catch (err: any) {
|
|
|
- setErrorMessage(`加入课堂失败: ${err.message}`);
|
|
|
- showToast('error', '加入课堂失败');
|
|
|
-
|
|
|
- // 如果IM加入成功但RTC加入失败,需要离开IM群组
|
|
|
- if (imGroupManager.current) {
|
|
|
- try {
|
|
|
- await imGroupManager.current.leaveGroup(classId);
|
|
|
- } catch (leaveErr) {
|
|
|
- console.error('离开IM群组失败:', leaveErr);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // 离开课堂
|
|
|
- const leaveClass = async (): Promise<void> => {
|
|
|
- try {
|
|
|
- if (imGroupManager.current && classId) {
|
|
|
- await imGroupManager.current.leaveGroup(classId);
|
|
|
- }
|
|
|
- if (aliRtcEngine.current) {
|
|
|
- await leaveRtcChannel();
|
|
|
- }
|
|
|
-
|
|
|
- setIsJoinedClass(false);
|
|
|
- showToast('info', '已离开课堂');
|
|
|
- } catch (err) {
|
|
|
- console.error('离开课堂失败:', err);
|
|
|
- showToast('error', '离开课堂时发生错误');
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // 发送消息
|
|
|
- const sendMessage = async (): Promise<void> => {
|
|
|
- if (!imMessageManager.current || !classId) return;
|
|
|
-
|
|
|
- try {
|
|
|
- await imMessageManager.current.sendGroupMessage({
|
|
|
- groupId: classId,
|
|
|
- data: msgText,
|
|
|
- type: 88888,
|
|
|
- level: ImMessageLevel.NORMAL,
|
|
|
- });
|
|
|
- setMsgText('');
|
|
|
- setErrorMessage('');
|
|
|
- } catch (err: any) {
|
|
|
- setErrorMessage(`消息发送失败: ${err.message}`);
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // 开始上课
|
|
|
- const startClass = async (): Promise<void> => {
|
|
|
- if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
|
|
|
-
|
|
|
- try {
|
|
|
-
|
|
|
- // 发送开始上课消息
|
|
|
- await imMessageManager.current.sendGroupMessage({
|
|
|
- groupId: classId,
|
|
|
- data: JSON.stringify({ action: 'start_class' }),
|
|
|
- type: 88889, // 自定义消息类型
|
|
|
- level: ImMessageLevel.HIGH,
|
|
|
- });
|
|
|
- setClassStatus(ClassStatus.IN_PROGRESS);
|
|
|
- showToast('success', '课堂已开始');
|
|
|
- } catch (err: any) {
|
|
|
- setErrorMessage(`开始上课失败: ${err.message}`);
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // 结束上课
|
|
|
- const endClass = async (): Promise<void> => {
|
|
|
- if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
|
|
|
-
|
|
|
- try {
|
|
|
- await imMessageManager.current.sendGroupMessage({
|
|
|
- groupId: classId,
|
|
|
- data: JSON.stringify({ action: 'end_class' }),
|
|
|
- type: 88889, // 自定义消息类型
|
|
|
- level: ImMessageLevel.HIGH,
|
|
|
- });
|
|
|
- setClassStatus(ClassStatus.ENDED);
|
|
|
- showToast('success', '课堂已结束');
|
|
|
-
|
|
|
- // 离开RTC频道
|
|
|
- try {
|
|
|
- await leaveRtcChannel();
|
|
|
- } catch (err: any) {
|
|
|
- console.error('离开RTC频道失败:', err);
|
|
|
- showToast('error', '离开RTC频道失败');
|
|
|
- }
|
|
|
- } catch (err: any) {
|
|
|
- setErrorMessage(`结束上课失败: ${err.message}`);
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // 静音/取消静音成员
|
|
|
- const toggleMuteMember = async (userId: string, mute: boolean): Promise<void> => {
|
|
|
- if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
|
|
|
-
|
|
|
- try {
|
|
|
- await imMessageManager.current.sendGroupMessage({
|
|
|
- groupId: classId,
|
|
|
- data: JSON.stringify({
|
|
|
- action: 'toggle_mute',
|
|
|
- userId,
|
|
|
- mute
|
|
|
- }),
|
|
|
- type: 88890, // 自定义消息类型
|
|
|
- level: ImMessageLevel.HIGH,
|
|
|
- });
|
|
|
- showToast('info', mute ? `已静音用户 ${userId}` : `已取消静音用户 ${userId}`);
|
|
|
- } catch (err: any) {
|
|
|
- setErrorMessage(`操作失败: ${err.message}`);
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // 创建课堂
|
|
|
- const createClass = async (className: string, maxMembers = 200): Promise<string | null> => {
|
|
|
- if (!imEngine.current || !isLoggedIn || role !== Role.Teacher) {
|
|
|
- showToast('error', '只有老师可以创建课堂');
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- const groupManager = imEngine.current.getGroupManager();
|
|
|
- if (!groupManager) {
|
|
|
- throw new Error('群组管理器未初始化');
|
|
|
- }
|
|
|
-
|
|
|
- // 显示创建中状态
|
|
|
- showToast('info', '正在创建课堂...');
|
|
|
-
|
|
|
- // 调用IM SDK创建群组
|
|
|
- const response = await groupManager.createGroup({
|
|
|
- groupName: className,
|
|
|
- groupMeta: JSON.stringify({
|
|
|
- classType: 'interactive',
|
|
|
- creator: userId,
|
|
|
- createdAt: Date.now(),
|
|
|
- maxMembers
|
|
|
- })
|
|
|
- });
|
|
|
-
|
|
|
- if (!response?.groupId) {
|
|
|
- throw new Error('创建群组失败: 未返回群组ID');
|
|
|
- }
|
|
|
-
|
|
|
- // 创建成功后自动加入群组
|
|
|
- try {
|
|
|
- await groupManager.joinGroup(response.groupId);
|
|
|
-
|
|
|
-
|
|
|
- showToast('success', '课堂创建并加入成功');
|
|
|
- showMessage(`课堂 ${className} 创建成功,ID: ${response.groupId}`);
|
|
|
-
|
|
|
- // 更新状态
|
|
|
- setClassId(response.groupId);
|
|
|
- setIsJoinedClass(true);
|
|
|
-
|
|
|
- // 初始化群组消息管理器
|
|
|
- const messageManager = imEngine.current.getMessageManager();
|
|
|
- if (messageManager) {
|
|
|
- imMessageManager.current = messageManager;
|
|
|
- listenMessageEvents();
|
|
|
- }
|
|
|
-
|
|
|
- // 加入RTC频道
|
|
|
- await joinRtcChannel(response.groupId);
|
|
|
-
|
|
|
- // 记录创建时间
|
|
|
- const createTime = new Date();
|
|
|
- showMessage(`创建时间: ${createTime.toLocaleString()}`);
|
|
|
-
|
|
|
- // 创建成功后生成分享链接
|
|
|
- setShareLink(`${window.location.href.split('?')[0]}?classId=${response.groupId}`);
|
|
|
-
|
|
|
- return response.groupId;
|
|
|
- } catch (joinErr: any) {
|
|
|
- throw new Error(`创建成功但加入失败: ${joinErr.message}`);
|
|
|
- }
|
|
|
- } catch (err: any) {
|
|
|
- const errorMsg = err.message.includes('alreadyExist')
|
|
|
- ? '课堂已存在'
|
|
|
- : `课堂创建失败: ${err.message}`;
|
|
|
-
|
|
|
- setErrorMessage(errorMsg);
|
|
|
- showToast('error', errorMsg);
|
|
|
- return null;
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // 加入RTC频道
|
|
|
- const joinRtcChannel = async (classId: string, publishOptions?: {
|
|
|
- publishVideo?: boolean
|
|
|
- publishAudio?: boolean
|
|
|
- publishScreen?: boolean
|
|
|
- }) => {
|
|
|
- if (!aliRtcEngine.current) return;
|
|
|
- const {
|
|
|
- publishVideo = false,
|
|
|
- publishAudio = false,
|
|
|
- publishScreen = false,
|
|
|
- } = publishOptions || {};
|
|
|
-
|
|
|
- const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
|
|
|
- const token = await generateToken(RTC_APP_ID, RTC_APP_KEY, classId, userId, timestamp);
|
|
|
- await aliRtcEngine.current.publishLocalVideoStream(publishVideo);
|
|
|
- await aliRtcEngine.current.publishLocalAudioStream(publishAudio);
|
|
|
- await aliRtcEngine.current.publishLocalScreenShareStream(publishScreen);
|
|
|
- await aliRtcEngine.current.joinChannel(
|
|
|
- {
|
|
|
- channelId: classId,
|
|
|
- userId,
|
|
|
- appId: RTC_APP_ID,
|
|
|
- token,
|
|
|
- timestamp,
|
|
|
- },
|
|
|
- userId
|
|
|
- );
|
|
|
- // showToast('info', '已加入RTC频道');
|
|
|
- };
|
|
|
-
|
|
|
- // 离开RTC频道
|
|
|
- const leaveRtcChannel = async () => {
|
|
|
- if (!aliRtcEngine.current) return;
|
|
|
- await aliRtcEngine.current.leaveChannel();
|
|
|
- // showToast('info', '已离开RTC频道');
|
|
|
- };
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- // 切换摄像头状态
|
|
|
- const toggleCamera = async () => {
|
|
|
- if(!aliRtcEngine.current?.isInCall){
|
|
|
- showToast('error', '先加入课堂');
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- if (isCameraOn) {
|
|
|
- await aliRtcEngine.current?.stopPreview();
|
|
|
- await aliRtcEngine.current?.enableLocalVideo(false)
|
|
|
- await aliRtcEngine.current?.publishLocalVideoStream(false)
|
|
|
- } else {
|
|
|
- await aliRtcEngine.current?.setLocalViewConfig('localPreviewer', AliRtcVideoTrack.AliRtcVideoTrackCamera);
|
|
|
- await aliRtcEngine.current?.enableLocalVideo(true)
|
|
|
- await aliRtcEngine.current?.startPreview();
|
|
|
- await aliRtcEngine.current?.publishLocalVideoStream(true)
|
|
|
-
|
|
|
- }
|
|
|
- await aliRtcEngine.current?.startAndPublishDefaultDevices()
|
|
|
- setIsCameraOn(!isCameraOn);
|
|
|
- } catch (err) {
|
|
|
- console.error('切换摄像头状态失败:', err);
|
|
|
- showToast('error', '切换摄像头失败');
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // 切换音频状态
|
|
|
- const toggleAudio = async () => {
|
|
|
- if(!aliRtcEngine.current?.isInCall){
|
|
|
- showToast('error', '先加入课堂');
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- if (isAudioOn) {
|
|
|
- await aliRtcEngine.current?.stopAudioCapture()
|
|
|
- await aliRtcEngine.current?.publishLocalAudioStream(false);
|
|
|
- } else {
|
|
|
- await aliRtcEngine.current?.publishLocalAudioStream(true);
|
|
|
- }
|
|
|
- await aliRtcEngine.current?.startAndPublishDefaultDevices();
|
|
|
- setIsAudioOn(!isAudioOn);
|
|
|
- } catch (err) {
|
|
|
- console.error('切换麦克风状态失败:', err);
|
|
|
- showToast('error', '切换麦克风失败');
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // 切换屏幕分享状态
|
|
|
- const toggleScreenShare = async () => {
|
|
|
- if(!aliRtcEngine.current?.isInCall){
|
|
|
- showToast('error', '先加入课堂');
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- if (isScreenSharing) {
|
|
|
- await aliRtcEngine.current?.publishLocalScreenShareStream(false)
|
|
|
- await aliRtcEngine.current?.stopScreenShare()
|
|
|
- } else {
|
|
|
- await aliRtcEngine.current?.publishLocalScreenShareStream(true)
|
|
|
- await aliRtcEngine.current?.setLocalViewConfig(
|
|
|
- 'screenPreviewer',
|
|
|
- AliRtcVideoTrack.AliRtcVideoTrackScreen
|
|
|
- );
|
|
|
- }
|
|
|
- await aliRtcEngine.current?.startAndPublishDefaultDevices()
|
|
|
- setIsScreenSharing(!isScreenSharing);
|
|
|
- } catch (err) {
|
|
|
- console.error('切换屏幕分享失败:', err);
|
|
|
- showToast('error', '切换屏幕分享失败');
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // 清理资源
|
|
|
- useEffect(() => {
|
|
|
- return () => {
|
|
|
- if (imGroupManager.current) {
|
|
|
- imGroupManager.current.removeAllListeners();
|
|
|
- }
|
|
|
- if (imMessageManager.current) {
|
|
|
- imMessageManager.current.removeAllListeners();
|
|
|
- }
|
|
|
- if (imEngine.current) {
|
|
|
- imEngine.current.removeAllListeners();
|
|
|
- }
|
|
|
- if (aliRtcEngine.current) {
|
|
|
- aliRtcEngine.current.destroy();
|
|
|
- }
|
|
|
- };
|
|
|
- }, []);
|
|
|
-
|
|
|
- // 学生举手
|
|
|
- const handUp = async (question?: string): Promise<void> => {
|
|
|
- if (!imMessageManager.current || !classId || role !== 'student') return;
|
|
|
-
|
|
|
- try {
|
|
|
- await imMessageManager.current.sendGroupMessage({
|
|
|
- groupId: classId,
|
|
|
- data: JSON.stringify({
|
|
|
- action: 'hand_up',
|
|
|
- studentId: userId,
|
|
|
- timestamp: Date.now(),
|
|
|
- question
|
|
|
- }),
|
|
|
- type: 88891,
|
|
|
- level: ImMessageLevel.NORMAL,
|
|
|
- });
|
|
|
- } catch (err: any) {
|
|
|
- setErrorMessage(`举手失败: ${err.message}`);
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // 老师应答举手
|
|
|
- const answerHandUp = async (studentId: string): Promise<void> => {
|
|
|
- if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
|
|
|
-
|
|
|
- try {
|
|
|
- await imMessageManager.current.sendGroupMessage({
|
|
|
- groupId: classId,
|
|
|
- data: JSON.stringify({
|
|
|
- action: 'answer_hand_up',
|
|
|
- studentId
|
|
|
- }),
|
|
|
- type: 88893,
|
|
|
- level: ImMessageLevel.HIGH,
|
|
|
- });
|
|
|
- } catch (err: any) {
|
|
|
- setErrorMessage(`应答失败: ${err.message}`);
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // 发送问题
|
|
|
- const sendQuestion = async (question: string): Promise<void> => {
|
|
|
- if (!imMessageManager.current || !classId) return;
|
|
|
-
|
|
|
- try {
|
|
|
- await imMessageManager.current.sendGroupMessage({
|
|
|
- groupId: classId,
|
|
|
- data: question,
|
|
|
- type: 88892,
|
|
|
- level: ImMessageLevel.NORMAL,
|
|
|
- });
|
|
|
- } catch (err: any) {
|
|
|
- setErrorMessage(`问题发送失败: ${err.message}`);
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- return (
|
|
|
- <ClassroomContext.Provider value={{
|
|
|
- userId,
|
|
|
- role,
|
|
|
- isLoggedIn,
|
|
|
- isJoinedClass,
|
|
|
- messageList,
|
|
|
- errorMessage,
|
|
|
- classStatus,
|
|
|
- handUpList,
|
|
|
- questions,
|
|
|
- setRole: (role: Role) => setRole(role as Role),
|
|
|
- createClass,
|
|
|
- startClass,
|
|
|
- endClass,
|
|
|
- toggleMuteMember,
|
|
|
- handUp,
|
|
|
- answerHandUp,
|
|
|
- sendQuestion
|
|
|
- }}>
|
|
|
- <div className="container mx-auto p-4">
|
|
|
- <h1 className="text-2xl font-bold mb-4">互动课堂</h1>
|
|
|
-
|
|
|
- <ToastContainer
|
|
|
- position="top-right"
|
|
|
- autoClose={5000}
|
|
|
- hideProgressBar={false}
|
|
|
- newestOnTop={false}
|
|
|
- closeOnClick
|
|
|
- rtl={false}
|
|
|
- pauseOnFocusLoss
|
|
|
- draggable
|
|
|
- pauseOnHover
|
|
|
- />
|
|
|
-
|
|
|
- <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
|
- <div className="md:col-span-1">
|
|
|
- {shareLink && (
|
|
|
- <div className="mb-4 p-4 bg-white rounded-md shadow">
|
|
|
- <h4 className="text-lg font-medium mb-2">课堂分享链接</h4>
|
|
|
- <div className="flex items-center">
|
|
|
- <input
|
|
|
- type="text"
|
|
|
- readOnly
|
|
|
- value={shareLink}
|
|
|
- className="flex-1 px-3 py-2 border border-gray-300 rounded-l-md"
|
|
|
- />
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="px-3 py-2 bg-blue-600 text-white rounded-r-md"
|
|
|
- onClick={() => {
|
|
|
- navigator.clipboard.writeText(shareLink);
|
|
|
- showToast('info', '链接已复制');
|
|
|
- }}
|
|
|
- >
|
|
|
- 复制
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- <form>
|
|
|
- {!isLoggedIn && (
|
|
|
- <div className="mb-2">
|
|
|
- <label className="block text-sm font-medium text-gray-700">课堂名称</label>
|
|
|
- <input
|
|
|
- className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
|
|
|
- value={className}
|
|
|
- onChange={(e) => setClassName(e.target.value)}
|
|
|
- placeholder="输入课堂名称"
|
|
|
- />
|
|
|
- </div>
|
|
|
- )}
|
|
|
- <div className="mb-2">
|
|
|
- <label className="block text-sm font-medium text-gray-700">用户ID</label>
|
|
|
- <input
|
|
|
- className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
|
|
|
- value={userId}
|
|
|
- onChange={(e) => setUserId(e.target.value)}
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="mb-2">
|
|
|
- <label className="block text-sm font-medium text-gray-700">课堂ID</label>
|
|
|
- <input
|
|
|
- className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
|
|
|
- value={classId}
|
|
|
- onChange={(e) => setClassId(e.target.value)}
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="mb-2">
|
|
|
- <label className="block text-sm font-medium text-gray-700">角色</label>
|
|
|
- <select
|
|
|
- className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
|
|
|
- value={role}
|
|
|
- onChange={(e) => setRole(e.target.value as Role)}
|
|
|
- >
|
|
|
- <option value={Role.Student}>学生</option>
|
|
|
- <option value={Role.Teacher}>老师</option>
|
|
|
- </select>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="flex space-x-2 mb-2">
|
|
|
- {!isLoggedIn && (
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="px-3 py-2 bg-blue-600 text-white rounded-md"
|
|
|
- onClick={() => login(userId)}
|
|
|
- >
|
|
|
- 登录
|
|
|
- </button>
|
|
|
- )}
|
|
|
-
|
|
|
- {isLoggedIn && role === Role.Teacher && (
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="px-3 py-2 bg-green-600 text-white rounded-md"
|
|
|
- disabled={!className}
|
|
|
- onClick={async () => {
|
|
|
- const classId = await createClass(className);
|
|
|
- if (classId) {
|
|
|
- setClassId(classId);
|
|
|
- }
|
|
|
- }}
|
|
|
- >
|
|
|
- 创建课堂
|
|
|
- </button>
|
|
|
- )}
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="px-3 py-2 bg-blue-600 text-white rounded-md"
|
|
|
- disabled={!isLoggedIn || isJoinedClass}
|
|
|
- onClick={() => joinClass(classId)}
|
|
|
- >
|
|
|
- 加入课堂
|
|
|
- </button>
|
|
|
-
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="px-3 py-2 bg-gray-600 text-white rounded-md"
|
|
|
- disabled={!isJoinedClass}
|
|
|
- onClick={leaveClass}
|
|
|
- >
|
|
|
- 离开课堂
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </form>
|
|
|
-
|
|
|
- <div className="mt-4">
|
|
|
- <label className="block text-sm font-medium text-gray-700">消息</label>
|
|
|
- <input
|
|
|
- className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
|
|
|
- value={msgText}
|
|
|
- onChange={(e) => setMsgText(e.target.value)}
|
|
|
- />
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="mt-2 px-3 py-2 bg-blue-600 text-white rounded-md"
|
|
|
- disabled={!isJoinedClass}
|
|
|
- onClick={sendMessage}
|
|
|
- >
|
|
|
- 发送
|
|
|
- </button>
|
|
|
- </div>
|
|
|
-
|
|
|
- {role === 'student' && isJoinedClass && (
|
|
|
- <div className="mt-4 p-4 bg-white rounded-md shadow">
|
|
|
- <h4 className="text-lg font-medium mb-2">互动功能</h4>
|
|
|
- <div className="space-y-3">
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="w-full px-3 py-2 bg-green-600 text-white rounded-md"
|
|
|
- onClick={() => handUp()}
|
|
|
- >
|
|
|
- 举手
|
|
|
- </button>
|
|
|
- <div className="flex space-x-2">
|
|
|
- <input
|
|
|
- type="text"
|
|
|
- placeholder="输入问题..."
|
|
|
- className="flex-1 px-3 py-2 border border-gray-300 rounded-md"
|
|
|
- id="questionInput"
|
|
|
- />
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="px-3 py-2 bg-blue-600 text-white rounded-md"
|
|
|
- onClick={() => {
|
|
|
- const input = document.getElementById('questionInput') as HTMLInputElement;
|
|
|
- if (input.value) {
|
|
|
- sendQuestion(input.value);
|
|
|
- input.value = '';
|
|
|
- }
|
|
|
- }}
|
|
|
- >
|
|
|
- 提问
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {role === Role.Teacher && handUpList.length > 0 && (
|
|
|
- <div className="mt-4 p-4 bg-white rounded-md shadow">
|
|
|
- <h4 className="text-lg font-medium mb-2">举手列表 ({handUpList.length})</h4>
|
|
|
- <div className="space-y-2">
|
|
|
- {handUpList.map((req, i) => (
|
|
|
- <div key={i} className="flex items-center justify-between p-2 border-b">
|
|
|
- <div>
|
|
|
- <div className="font-medium">{req.studentName || req.studentId}</div>
|
|
|
- {req.question && <div className="text-sm text-gray-500">{req.question}</div>}
|
|
|
- </div>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="px-2 py-1 bg-blue-600 text-white rounded text-sm"
|
|
|
- onClick={() => answerHandUp(req.studentId)}
|
|
|
- >
|
|
|
- 应答
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {questions.length > 0 && (
|
|
|
- <div className="mt-4 p-4 bg-white rounded-md shadow">
|
|
|
- <h4 className="text-lg font-medium mb-2">问题列表 ({questions.length})</h4>
|
|
|
- <div className="space-y-2">
|
|
|
- {questions.map((q, i) => (
|
|
|
- <div key={i} className="p-2 border-b">
|
|
|
- <div className="font-medium">问题 {i + 1}</div>
|
|
|
- <div className="text-gray-700">{q}</div>
|
|
|
- </div>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="md:col-span-1">
|
|
|
- <h4 className="text-lg font-medium mb-2">消息记录</h4>
|
|
|
- <div className="bg-gray-100 p-2 rounded-md h-64 overflow-y-auto">
|
|
|
- {messageList.map((msg, i) => (
|
|
|
- <div key={i} className="mb-1">{msg}</div>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
-
|
|
|
- {role === Role.Teacher && isJoinedClass && (
|
|
|
- <div className="mt-4 p-4 bg-white rounded-md shadow">
|
|
|
- <h4 className="text-lg font-medium mb-2">老师控制面板</h4>
|
|
|
- <div className="flex space-x-2 mb-4">
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="px-3 py-2 bg-green-600 text-white rounded-md"
|
|
|
- disabled={classStatus === ClassStatus.IN_PROGRESS}
|
|
|
- onClick={startClass}
|
|
|
- >
|
|
|
- 开始上课
|
|
|
- </button>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="px-3 py-2 bg-red-600 text-white rounded-md"
|
|
|
- disabled={classStatus !== ClassStatus.IN_PROGRESS}
|
|
|
- onClick={endClass}
|
|
|
- >
|
|
|
- 结束上课
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- <div>
|
|
|
- <h5 className="font-medium mb-2">成员管理</h5>
|
|
|
- <div className="space-y-2">
|
|
|
- {students.map(student => (
|
|
|
- <div key={student.id} className="flex items-center justify-between">
|
|
|
- <span>{student.name}</span>
|
|
|
- <div className="space-x-2">
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="px-2 py-1 bg-yellow-500 text-white rounded text-sm"
|
|
|
- onClick={() => toggleMuteMember(student.id, true)}
|
|
|
- >
|
|
|
- 静音
|
|
|
- </button>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="px-2 py-1 bg-blue-500 text-white rounded text-sm"
|
|
|
- onClick={() => toggleMuteMember(student.id, false)}
|
|
|
- >
|
|
|
- 取消静音
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="md:col-span-1">
|
|
|
- <div className="mb-4">
|
|
|
- <h4 className="text-lg font-medium mb-2">本地视频</h4>
|
|
|
- <div className="relative">
|
|
|
- <video
|
|
|
- id="localPreviewer"
|
|
|
- muted
|
|
|
- className="w-full h-48 bg-black"
|
|
|
- ></video>
|
|
|
- <div className="absolute bottom-2 right-2 flex space-x-2">
|
|
|
- <button
|
|
|
- onClick={toggleCamera}
|
|
|
- className={`px-3 py-1 rounded-md ${isCameraOn ? 'bg-red-600' : 'bg-blue-600'} text-white`}
|
|
|
- >
|
|
|
- {isCameraOn ? '关闭摄像头' : '开启摄像头'}
|
|
|
- </button>
|
|
|
- <button
|
|
|
- onClick={toggleAudio}
|
|
|
- className={`px-3 py-1 rounded-md ${isAudioOn ? 'bg-red-600' : 'bg-blue-600'} text-white`}
|
|
|
- >
|
|
|
- {isAudioOn ? '关闭麦克风' : '开启麦克风'}
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="mb-4">
|
|
|
- <h4 className="text-lg font-medium mb-2">屏幕分享</h4>
|
|
|
- <div className="relative">
|
|
|
- <video
|
|
|
- id="screenPreviewer"
|
|
|
- muted
|
|
|
- className="w-full h-48 bg-black"
|
|
|
- ></video>
|
|
|
- <div className="absolute bottom-2 right-2">
|
|
|
- <button
|
|
|
- onClick={toggleScreenShare}
|
|
|
- className={`px-3 py-1 rounded-md ${isScreenSharing ? 'bg-red-600' : 'bg-blue-600'} text-white`}
|
|
|
- >
|
|
|
- {isScreenSharing ? '停止分享' : '分享屏幕'}
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div>
|
|
|
- <h4 className="text-lg font-medium mb-2">远程视频</h4>
|
|
|
- <div
|
|
|
- id="remoteVideoContainer"
|
|
|
- ref={remoteVideoContainer}
|
|
|
- className="grid grid-cols-2 gap-2"
|
|
|
- ></div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- {errorMessage && (
|
|
|
- <div className="mt-2 text-red-500">{errorMessage}</div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- </ClassroomContext.Provider>
|
|
|
- );
|
|
|
-};
|