Jelajahi Sumber

✨ feat(classroom): 新增闭门课堂功能

- 添加闭门课堂创建选项,支持设置课堂私密性
- 实现闭门课堂权限控制,非老师和学员身份无法进入
- 增加闭门课堂视觉标识,显示"闭门"标签和锁图标

♻️ refactor(classroom): 优化课堂状态管理和权限控制

- 修改路由参数处理方式,从查询参数改为路径参数
- 优化课堂加入流程,添加isJoinedClass状态判断
- 重构useClassroom hook,增加isPrivateClass状态管理

🐛 fix(classroom): 修复课堂加入和权限检查问题

- 修复未加入课堂时获取静音状态的错误
- 修复课堂结束状态显示逻辑错误
- 修复创建课堂时参数传递问题

🔧 chore(classroom): 完善日志和调试信息

- 增加关键操作的console日志输出
- 添加错误处理和用户提示信息优化
- 调整UI布局和样式细节
yourname 6 bulan lalu
induk
melakukan
6ee037c8a7

+ 3 - 2
src/client/mobile/components/Classroom/AllMuteToggleButton.tsx

@@ -15,7 +15,7 @@ import {
 } from '@/client/components/ui/alert-dialog';
 } from '@/client/components/ui/alert-dialog';
 
 
 export const AllMuteToggleButton: React.FC = () => {
 export const AllMuteToggleButton: React.FC = () => {
-  const { role, muteAllIM, unmuteAllIM, classId, checkAllMuteStatus } = useClassroomContext();
+  const { role, muteAllIM, unmuteAllIM, classId, checkAllMuteStatus, isJoinedClass } = useClassroomContext();
   const [isAllMuted, setIsAllMuted] = useState(false);
   const [isAllMuted, setIsAllMuted] = useState(false);
   const [loading, setLoading] = useState(false);
   const [loading, setLoading] = useState(false);
 
 
@@ -23,6 +23,7 @@ export const AllMuteToggleButton: React.FC = () => {
   useEffect(() => {
   useEffect(() => {
     const fetchAllMuteStatus = async () => {
     const fetchAllMuteStatus = async () => {
       if (!classId) return;
       if (!classId) return;
+      if (!isJoinedClass) return;
       
       
       try {
       try {
         const muted = await checkAllMuteStatus();
         const muted = await checkAllMuteStatus();
@@ -33,7 +34,7 @@ export const AllMuteToggleButton: React.FC = () => {
     };
     };
     
     
     fetchAllMuteStatus();
     fetchAllMuteStatus();
-  }, [classId, checkAllMuteStatus]);
+  }, [classId, isJoinedClass,  checkAllMuteStatus]);
   const [confirmDialog, setConfirmDialog] = useState<{
   const [confirmDialog, setConfirmDialog] = useState<{
     open: boolean;
     open: boolean;
     action: 'muteAll' | 'unmuteAll' | null;
     action: 'muteAll' | 'unmuteAll' | null;

+ 11 - 1
src/client/mobile/components/Classroom/ClassroomLayout.tsx

@@ -10,6 +10,7 @@ import {
   PaperAirplaneIcon,
   PaperAirplaneIcon,
   ArrowsPointingOutIcon,
   ArrowsPointingOutIcon,
   ArrowsPointingInIcon,
   ArrowsPointingInIcon,
+  LockClosedIcon,
 } from '@heroicons/react/24/outline';
 } from '@heroicons/react/24/outline';
 import { Button } from '@/client/components/ui/button';
 import { Button } from '@/client/components/ui/button';
 import { Textarea } from '@/client/components/ui/textarea';
 import { Textarea } from '@/client/components/ui/textarea';
@@ -54,7 +55,8 @@ export const ClassroomLayout = ({ children, role }: ClassroomLayoutProps) => {
     shareLink,
     shareLink,
     showCameraOverlay,
     showCameraOverlay,
     setShowCameraOverlay,
     setShowCameraOverlay,
-    showToast
+    showToast,
+    isPrivateClass
   } = useClassroomContext();
   } = useClassroomContext();
 
 
   // 检测是否为移动设备
   // 检测是否为移动设备
@@ -231,6 +233,14 @@ export const ClassroomLayout = ({ children, role }: ClassroomLayoutProps) => {
                 </Button>
                 </Button>
               )}
               )}
 
 
+              {/* 闭门标识显示 */}
+              {isPrivateClass && (
+                <div className="flex items-center p-2 bg-purple-100 text-purple-800 rounded-full" title="闭门会议(仅限学员进入)">
+                  <LockClosedIcon className="w-4 h-4 mr-1" />
+                  <span className="text-xs font-medium">闭门</span>
+                </div>
+              )}
+
               {/* 横屏按钮 - 仅在移动设备上显示 */}
               {/* 横屏按钮 - 仅在移动设备上显示 */}
               {isMobileDevice() && (
               {isMobileDevice() && (
                 <Button
                 <Button

+ 61 - 15
src/client/mobile/components/Classroom/useClassroom.ts

@@ -1,5 +1,5 @@
 import { useState, useEffect, useRef } from 'react';
 import { useState, useEffect, useRef } from 'react';
-import { useParams } from 'react-router';
+import { useParams, useSearchParams } from 'react-router';
 // import { ClassroomAPI } from '../../api/index.ts';
 // import { ClassroomAPI } from '../../api/index.ts';
 // @ts-types="../../../share/aliyun-rtc-sdk.d.ts"
 // @ts-types="../../../share/aliyun-rtc-sdk.d.ts"
 // @ts-types="./alivc-im.iife.d.ts"
 // @ts-types="./alivc-im.iife.d.ts"
@@ -84,12 +84,14 @@ export interface Message {
 export const useClassroom = ({ user }:{ user : User }) => {
 export const useClassroom = ({ user }:{ user : User }) => {
   // 状态管理
   // 状态管理
   // const [userId, setUserId] = useState<string>(''); // 保持string类型
   // const [userId, setUserId] = useState<string>(''); // 保持string类型
+
+  const [searchParams] = useSearchParams();
   const userId = user.id.toString();
   const userId = user.id.toString();
   const [isCameraOn, setIsCameraOn] = useState<boolean>(false);
   const [isCameraOn, setIsCameraOn] = useState<boolean>(false);
   const [isAudioOn, setIsAudioOn] = useState<boolean>(false);
   const [isAudioOn, setIsAudioOn] = useState<boolean>(false);
   const [isScreenSharing, setIsScreenSharing] = useState<boolean>(false);
   const [isScreenSharing, setIsScreenSharing] = useState<boolean>(false);
   const [className, setClassName] = useState<string>('');
   const [className, setClassName] = useState<string>('');
-  const [role, setRole] = useState<Role | undefined>();
+  const [role, setRole] = useState<Role>(searchParams.get('role') === Role.Teacher ? Role.Teacher : Role.Student);
   const [classId, setClassId] = useState<string>('');
   const [classId, setClassId] = useState<string>('');
   const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
   const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
   const [isJoinedClass, setIsJoinedClass] = useState<boolean>(false);
   const [isJoinedClass, setIsJoinedClass] = useState<boolean>(false);
@@ -98,6 +100,7 @@ export const useClassroom = ({ user }:{ user : User }) => {
   const [errorMessage, setErrorMessage] = useState<string>('');
   const [errorMessage, setErrorMessage] = useState<string>('');
   const [classStatus, setClassStatus] = useState<ClassStatus>(ClassStatus.NOT_STARTED);
   const [classStatus, setClassStatus] = useState<ClassStatus>(ClassStatus.NOT_STARTED);
   const [handUpList, setHandUpList] = useState<HandUpRequest[]>([]);
   const [handUpList, setHandUpList] = useState<HandUpRequest[]>([]);
+  const [isPrivateClass, setIsPrivateClass] = useState<boolean>(false);
   const [questions, setQuestions] = useState<Question[]>([]);
   const [questions, setQuestions] = useState<Question[]>([]);
   const [students, setStudents] = useState<Array<{id: string, name: string}>>([]);
   const [students, setStudents] = useState<Array<{id: string, name: string}>>([]);
   const [shareLink, setShareLink] = useState<string>('');
   const [shareLink, setShareLink] = useState<string>('');
@@ -601,6 +604,7 @@ export const useClassroom = ({ user }:{ user : User }) => {
     } catch (err: any) {
     } catch (err: any) {
       setErrorMessage(`登录失败: ${err.message}`);
       setErrorMessage(`登录失败: ${err.message}`);
       showToast('error', '登录失败');
       showToast('error', '登录失败');
+      console.error('登录失败', err)
     }
     }
   };
   };
 
 
@@ -612,7 +616,49 @@ export const useClassroom = ({ user }:{ user : User }) => {
       const mm = imEngine.current.getMessageManager();
       const mm = imEngine.current.getMessageManager();
       imGroupManager.current = gm || null;
       imGroupManager.current = gm || null;
       imMessageManager.current = mm || null;
       imMessageManager.current = mm || null;
+
+      // 得先加入群组才能获取到群组信息
       await gm!.joinGroup(classId);
       await gm!.joinGroup(classId);
+
+      // 先获取群组信息并检查闭门标识
+      let groupInfo;
+      let hasPermission = true; // 权限检查标志
+      try {
+        groupInfo = await gm!.queryGroup(classId);
+        
+        // 检查闭门课堂权限
+        if (groupInfo && groupInfo.groupMeta) {
+          try {
+            const meta = JSON.parse(groupInfo.groupMeta);
+            if (meta.isPrivate) {
+              // 设置闭门标识状态
+              setIsPrivateClass(true);
+              // 闭门课堂,允许老师和学员身份进入
+              console.log('user.userType', user.userType)
+              if (user.userType !== UserType.TRAINEE && user.userType !== UserType.TEACHER) {
+                // 不抛出错误,而是设置错误状态并阻止后续操作
+                setErrorMessage('该课堂为闭门会议,只有老师和学员身份可以进入');
+                setIsPrivateClass(true);
+                hasPermission = false;
+              }
+            } else {
+              setIsPrivateClass(false);
+            }
+          } catch (parseError) {
+            console.error('解析群组元数据失败:', parseError);
+            setIsPrivateClass(false);
+          }
+        }
+      } catch (err) {
+        console.error('获取群组信息失败:', err);
+      }
+
+      // 如果没有权限,抛出错误统一处理
+      if (!hasPermission) {
+        console.log('该课堂为闭门会议,只有老师和学员身份可以进入')
+        throw new Error('该课堂为闭门会议,只有老师和学员身份可以进入');
+      }
+
       listenGroupEvents();
       listenGroupEvents();
       listenMessageEvents();
       listenMessageEvents();
 
 
@@ -648,17 +694,11 @@ export const useClassroom = ({ user }:{ user : User }) => {
       }
       }
 
 
       // 获取群组统计信息
       // 获取群组统计信息
-      try {
-        const groupInfo = await gm!.queryGroup(classId);
-        if (groupInfo && groupInfo.statistics) {
-          setOnlineCount(groupInfo.statistics.onlineCount || 0);
-          setPvCount(groupInfo.statistics.pv || 0);
-          console.log('群组统计信息:', groupInfo.statistics);
-        }
-      } catch (err) {
-        console.error('获取群组统计信息失败:', err);
+      if (groupInfo && groupInfo.statistics) {
+        setOnlineCount(groupInfo.statistics.onlineCount || 0);
+        setPvCount(groupInfo.statistics.pv || 0);
+        console.log('群组统计信息:', groupInfo.statistics);
       }
       }
-
       await joinRtcChannel(classId);
       await joinRtcChannel(classId);
 
 
       buildShareLink(classId)
       buildShareLink(classId)
@@ -674,6 +714,7 @@ export const useClassroom = ({ user }:{ user : User }) => {
       } else {
       } else {
         setErrorMessage(`加入课堂失败: ${err.message}`);
         setErrorMessage(`加入课堂失败: ${err.message}`);
         showToast('error', '加入课堂失败');
         showToast('error', '加入课堂失败');
+        console.error('加入课堂失败', err)
       }
       }
       
       
       if (imGroupManager.current) {
       if (imGroupManager.current) {
@@ -855,7 +896,7 @@ export const useClassroom = ({ user }:{ user : User }) => {
     setShareLink(`${baseUrl}/mobile/classroom/${classId}/student`);
     setShareLink(`${baseUrl}/mobile/classroom/${classId}/student`);
   }
   }
 
 
-  const createClass = async (className: string, maxMembers = 200): Promise<string | null> => {
+  const createClass = async (className: string, maxMembers = 200, isPrivate = false): Promise<string | null> => {
     if (!imEngine.current || !isLoggedIn || role !== Role.Teacher) {
     if (!imEngine.current || !isLoggedIn || role !== Role.Teacher) {
       showToast('error', '只有老师可以创建课堂');
       showToast('error', '只有老师可以创建课堂');
       return null;
       return null;
@@ -867,7 +908,10 @@ export const useClassroom = ({ user }:{ user : User }) => {
         throw new Error('群组管理器未初始化');
         throw new Error('群组管理器未初始化');
       }
       }
 
 
-      showToast('info', '正在创建课堂...');
+      if(isPrivate)
+        showToast('info', '正在创建闭门课堂...');
+      else
+        showToast('info', '正在创建公开课堂...');
       
       
       const response = await gm.createGroup({
       const response = await gm.createGroup({
         groupName: className,
         groupName: className,
@@ -875,7 +919,8 @@ export const useClassroom = ({ user }:{ user : User }) => {
           classType: 'interactive',
           classType: 'interactive',
           creator: userId,
           creator: userId,
           createdAt: Date.now(),
           createdAt: Date.now(),
-          maxMembers
+          maxMembers,
+          isPrivate: isPrivate
         })
         })
       });
       });
 
 
@@ -1315,6 +1360,7 @@ export const useClassroom = ({ user }:{ user : User }) => {
     questions,
     questions,
     students,
     students,
     shareLink,
     shareLink,
+    isPrivateClass,
     remoteScreenContainer, // 重命名为remoteScreenContainer
     remoteScreenContainer, // 重命名为remoteScreenContainer
     remoteCameraContainer, // 导出摄像头容器ref
     remoteCameraContainer, // 导出摄像头容器ref
     showCameraOverlay,
     showCameraOverlay,

+ 64 - 45
src/client/mobile/pages/ClassroomPage.tsx

@@ -75,11 +75,12 @@ const JoinClassSection = () => {
 
 
 const CreateClassSection = () => {
 const CreateClassSection = () => {
   const { classStatus, createClass, className, setClassName, role } = useClassroomContext();
   const { classStatus, createClass, className, setClassName, role } = useClassroomContext();
+  const [isPrivate, setIsPrivate] = useState(false);
   const navigate = useNavigate();
   const navigate = useNavigate();
   
   
   const handleCreateClass = async () => {
   const handleCreateClass = async () => {
     if (!className.trim()) return;
     if (!className.trim()) return;
-    const classId = await createClass(className);
+    const classId = await createClass(className, 200, isPrivate);
     if (classId) {
     if (classId) {
       navigate(`/mobile/classroom/${classId}/${role === Role.Teacher ? Role.Teacher : Role.Student}`, { replace: true });
       navigate(`/mobile/classroom/${classId}/${role === Role.Teacher ? Role.Teacher : Role.Student}`, { replace: true });
     }
     }
@@ -93,20 +94,34 @@ const CreateClassSection = () => {
         <CardTitle className="text-lg">创建新课堂</CardTitle>
         <CardTitle className="text-lg">创建新课堂</CardTitle>
       </CardHeader>
       </CardHeader>
       <CardContent>
       <CardContent>
-        <div className="flex space-x-2">
-          <Input
-            type="text"
-            value={className}
-            onChange={(e) => setClassName(e.target.value)}
-            placeholder="输入课堂名称"
-            className="flex-1"
-          />
-          <Button
-            onClick={handleCreateClass}
-            variant="secondary"
-          >
-            创建课堂
-          </Button>
+        <div className="space-y-3">
+          <div className="flex space-x-2">
+            <Input
+              type="text"
+              value={className}
+              onChange={(e) => setClassName(e.target.value)}
+              placeholder="输入课堂名称"
+              className="flex-1"
+            />
+            <Button
+              onClick={handleCreateClass}
+              variant="secondary"
+            >
+              创建课堂
+            </Button>
+          </div>
+          <div className="flex items-center space-x-2">
+            <input
+              type="checkbox"
+              id="private-class"
+              checked={isPrivate}
+              onChange={(e) => setIsPrivate(e.target.checked)}
+              className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
+            />
+            <label htmlFor="private-class" className="text-sm text-gray-600">
+              闭门会议(仅限学员进入)
+            </label>
+          </div>
         </div>
         </div>
       </CardContent>
       </CardContent>
     </Card>
     </Card>
@@ -119,36 +134,53 @@ const Classroom = () => {
   const { role, classStatus, isLoggedIn, login, classId, joinClass, setRole } = context;
   const { role, classStatus, isLoggedIn, login, classId, joinClass, setRole } = context;
   const [searchParams] = useSearchParams();
   const [searchParams] = useSearchParams();
 
 
-  useEffect(() => {
-    // 处理URL中的role参数
-    const urlRole = searchParams.get('role');
-    if (urlRole && !role) {
-      const roleValue = urlRole === Role.Teacher ? Role.Teacher : Role.Student;
-      setRole(roleValue);
-      login(roleValue);
-    }
-  }, [searchParams]);
+  console.log('classStatus', classStatus)
+  console.log('classId', classId)
+  console.log('role', role)
+
+  // useEffect(() => {
+  //   // 处理URL中的role参数
+  //   const urlRole = searchParams.get('role');
+  //   if (!role) {
+  //     const roleValue = urlRole === Role.Teacher ? Role.Teacher : Role.Student;
+  //     setRole(roleValue);
+  //     login(roleValue);
+  //   }
+  // }, [searchParams]);
 
 
   useEffect(() => {
   useEffect(() => {
     
     
-    if (!isLoggedIn && role && classId) {
+    if (!isLoggedIn && role) {
       (async () => {
       (async () => {
         await login(role);
         await login(role);
-        await joinClass(classId);
+        if(classId)
+          await joinClass(classId);
       })()
       })()
-      
     }
     }
   }, [isLoggedIn, role , classId]);
   }, [isLoggedIn, role , classId]);
 
 
-  if (!role) {
+  if (classId && classStatus === ClassStatus.ENDED) {
     return (
     return (
-      <AuthLayout>
-        <RoleSelection />
-      </AuthLayout>
+      <Card className="text-center py-8">
+        <CardHeader>
+          <CardTitle className="text-xl">课堂已结束</CardTitle>
+        </CardHeader>
+        <CardContent>
+          <p>感谢参与本次课堂</p>
+        </CardContent>
+      </Card>
     );
     );
   }
   }
 
 
-  if (!isLoggedIn) {
+  // if (!role) {
+  //   return (
+  //     <AuthLayout>
+  //       <RoleSelection />
+  //     </AuthLayout>
+  //   );
+  // }
+
+  if (classId && !isLoggedIn) {
     return (
     return (
       <AuthLayout>
       <AuthLayout>
         <div className="flex items-center justify-center h-full">
         <div className="flex items-center justify-center h-full">
@@ -178,19 +210,6 @@ const Classroom = () => {
     );
     );
   }
   }
 
 
-  if (classStatus === ClassStatus.ENDED) {
-    return (
-      <Card className="text-center py-8">
-        <CardHeader>
-          <CardTitle className="text-xl">课堂已结束</CardTitle>
-        </CardHeader>
-        <CardContent>
-          <p>感谢参与本次课堂</p>
-        </CardContent>
-      </Card>
-    );
-  }
-
   return (
   return (
     <ClassroomLayout role={role}>
     <ClassroomLayout role={role}>
       <></>
       <></>

+ 2 - 2
src/client/mobile/pages/StockHomePage.tsx

@@ -8,9 +8,9 @@ export default function StockHomePage() {
 
 
   const handleClassroomClick = () => {
   const handleClassroomClick = () => {
     if (user?.userType === UserType.TEACHER) {
     if (user?.userType === UserType.TEACHER) {
-      navigate('/mobile/classroom?role=' + UserType.TEACHER);
+      navigate('/mobile/classroom/' + UserType.TEACHER);
     } else {
     } else {
-      navigate('/mobile/classroom?role=' + UserType.STUDENT);
+      navigate('/mobile/classroom/' + UserType.STUDENT);
     }
     }
   };
   };
 
 

+ 11 - 1
src/client/mobile/routes.tsx

@@ -77,7 +77,17 @@ export const router = createBrowserRouter([
     ),
     ),
   },
   },
   {
   {
-    path: '/mobile/classroom/:id?/:role?',
+    path: '/mobile/classroom/:id/:role',
+    element: <ProtectedRoute><ClassroomPage /></ProtectedRoute>,
+    errorElement: <ErrorPage />
+  },
+  {
+    path: '/mobile/classroom/:role',
+    element: <ProtectedRoute><ClassroomPage /></ProtectedRoute>,
+    errorElement: <ErrorPage />
+  },
+  {
+    path: '/mobile/classroom',
     element: <ProtectedRoute><ClassroomPage /></ProtectedRoute>,
     element: <ProtectedRoute><ClassroomPage /></ProtectedRoute>,
     errorElement: <ErrorPage />
     errorElement: <ErrorPage />
   },
   },