Przeglądaj źródła

✨ feat(classroom): 实现学生和教师互动界面

- 添加StudentView组件,包含举手提问和发送问题功能
- 添加TeacherView组件,实现课堂控制、学生管理、举手和问题管理功能
- 优化学生列表更新逻辑,处理加入和离开的用户
- 实现踢人功能,教师可移出学生,学生收到通知后自动离开
- 修复ClassroomPage中组件引用注释问题,正确渲染教师/学生视图
yourname 6 miesięcy temu
rodzic
commit
1a04339602

+ 97 - 0
src/client/mobile/components/Classroom/StudentView.tsx

@@ -0,0 +1,97 @@
+import React, { useState } from 'react';
+import { useClassroomContext } from './ClassroomProvider';
+import { 
+  HandRaisedIcon,
+  QuestionMarkCircleIcon
+} from '@heroicons/react/24/outline';
+
+export const StudentView: React.FC = () => {
+  const {
+    handUp,
+    sendQuestion,
+    classStatus
+  } = useClassroomContext();
+
+  const [questionText, setQuestionText] = useState('');
+
+  const handleHandUp = async () => {
+    const question = window.prompt('请输入您的问题(可选):');
+    await handUp(question || undefined);
+  };
+
+  const handleSendQuestion = async () => {
+    if (!questionText.trim()) {
+      alert('请输入问题内容');
+      return;
+    }
+    
+    await sendQuestion(questionText);
+    setQuestionText('');
+    alert('问题已发送');
+  };
+
+  return (
+    <div className="space-y-4 p-4">
+      {/* 举手功能 */}
+      <div className="bg-white rounded-lg shadow p-4">
+        <h3 className="text-lg font-semibold mb-4 flex items-center">
+          <HandRaisedIcon className="w-5 h-5 mr-2 text-orange-500" />
+          举手提问
+        </h3>
+        
+        <button
+          onClick={handleHandUp}
+          disabled={classStatus === 'ended'}
+          className={`w-full py-3 px-4 rounded-lg text-white font-medium transition-colors ${
+            classStatus === 'ended'
+              ? 'bg-gray-400 cursor-not-allowed'
+              : 'bg-orange-500 hover:bg-orange-600'
+          }`}
+        >
+          举手提问
+        </button>
+        
+        <p className="text-sm text-gray-500 mt-2">
+          点击举手后,老师会收到通知并可以应答您的问题
+        </p>
+      </div>
+
+      {/* 发送问题 */}
+      <div className="bg-white rounded-lg shadow p-4">
+        <h3 className="text-lg font-semibold mb-4 flex items-center">
+          <QuestionMarkCircleIcon className="w-5 h-5 mr-2 text-purple-500" />
+          发送问题
+        </h3>
+        
+        <textarea
+          value={questionText}
+          onChange={(e) => setQuestionText(e.target.value)}
+          placeholder="请输入您的问题..."
+          className="w-full border rounded px-3 py-2 mb-3"
+          rows={3}
+        />
+        
+        <button
+          onClick={handleSendQuestion}
+          disabled={classStatus === 'ended' || !questionText.trim()}
+          className={`w-full py-2 px-4 rounded text-white transition-colors ${
+            classStatus === 'ended' || !questionText.trim()
+              ? 'bg-gray-400 cursor-not-allowed'
+              : 'bg-purple-500 hover:bg-purple-600'
+          }`}
+        >
+          发送问题
+        </button>
+      </div>
+
+      {/* 课堂状态提示 */}
+      {classStatus === 'ended' && (
+        <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
+          <p className="text-yellow-800 text-sm">
+            课堂已结束,您无法再进行互动操作
+          </p>
+        </div>
+      )}
+    </div>
+  );
+};

+ 192 - 0
src/client/mobile/components/Classroom/TeacherView.tsx

@@ -0,0 +1,192 @@
+import React from 'react';
+import { useClassroomContext } from './ClassroomProvider';
+import { ClassStatus } from './useClassroom';
+import { 
+  StopIcon, 
+  UserGroupIcon, 
+  HandRaisedIcon,
+  QuestionMarkCircleIcon
+} from '@heroicons/react/24/outline';
+
+export const TeacherView: React.FC = () => {
+  const {
+    endClass,
+    classStatus,
+    handUpList,
+    questions,
+    students,
+    shareLink,
+    toggleMuteMember,
+    answerHandUp,
+    kickStudent
+  } = useClassroomContext();
+
+  const handleEndClass = async () => {
+    if (window.confirm('确定要结束课堂吗?所有学生将被断开连接。')) {
+      await endClass();
+    }
+  };
+
+  const handleMuteStudent = (studentId: string, studentName: string) => {
+    if (window.confirm(`确定要静音 ${studentName} 吗?`)) {
+      toggleMuteMember(studentId, true);
+    }
+  };
+
+  const handleUnmuteStudent = (studentId: string, studentName: string) => {
+    if (window.confirm(`确定要取消静音 ${studentName} 吗?`)) {
+      toggleMuteMember(studentId, false);
+    }
+  };
+
+  const handleAnswerHandUp = (studentId: string, studentName: string) => {
+    answerHandUp(studentId);
+    alert(`已应答 ${studentName} 的举手`);
+  };
+
+  const handleKickStudent = (studentId: string, studentName: string) => {
+    if (window.confirm(`确定要移出 ${studentName} 吗?`)) {
+      kickStudent(studentId);
+    }
+  };
+
+  return (
+    <div className="space-y-4 p-4">
+      {/* 课堂控制区域 */}
+      <div className="bg-white rounded-lg shadow p-4">
+        <h3 className="text-lg font-semibold mb-4 flex items-center">
+          <StopIcon className="w-5 h-5 mr-2 text-red-500" />
+          课堂控制
+        </h3>
+        
+        <button
+          onClick={handleEndClass}
+          disabled={classStatus === ClassStatus.ENDED}
+          className={`w-full py-3 px-4 rounded-lg text-white font-medium transition-colors ${
+            classStatus === ClassStatus.ENDED
+              ? 'bg-gray-400 cursor-not-allowed'
+              : 'bg-red-500 hover:bg-red-600'
+          }`}
+        >
+          {classStatus === ClassStatus.ENDED ? '课堂已结束' : '结束课堂'}
+        </button>
+        
+        {shareLink && (
+          <div className="mt-4 p-3 bg-blue-50 rounded-lg">
+            <p className="text-sm text-blue-800 mb-2">课堂邀请链接:</p>
+            <div className="flex items-center gap-2">
+              <input
+                type="text"
+                value={shareLink}
+                readOnly
+                className="flex-1 text-sm border border-blue-200 rounded px-3 py-2 bg-white"
+              />
+              <button
+                onClick={() => navigator.clipboard.writeText(shareLink)}
+                className="px-3 py-2 bg-blue-500 text-white rounded text-sm hover:bg-blue-600"
+              >
+                复制
+              </button>
+            </div>
+          </div>
+        )}
+      </div>
+
+      {/* 学生管理区域 */}
+      <div className="bg-white rounded-lg shadow p-4">
+        <h3 className="text-lg font-semibold mb-4 flex items-center">
+          <UserGroupIcon className="w-5 h-5 mr-2 text-blue-500" />
+          学生管理 ({students.length})
+        </h3>
+        
+        {students.length === 0 ? (
+          <p className="text-gray-500 text-sm">暂无学生加入</p>
+        ) : (
+          <div className="space-y-2">
+            {students.map((student) => (
+              <div key={student.id} className="flex items-center justify-between p-2 border rounded">
+                <span className="text-sm">{student.name}</span>
+                <div className="flex gap-1">
+                  <button
+                    onClick={() => handleMuteStudent(student.id, student.name)}
+                    className="px-2 py-1 bg-yellow-500 text-white text-xs rounded hover:bg-yellow-600"
+                  >
+                    静音
+                  </button>
+                  <button
+                    onClick={() => handleUnmuteStudent(student.id, student.name)}
+                    className="px-2 py-1 bg-green-500 text-white text-xs rounded hover:bg-green-600"
+                  >
+                    取消静音
+                  </button>
+                  <button
+                    onClick={() => handleKickStudent(student.id, student.name)}
+                    className="px-2 py-1 bg-red-500 text-white text-xs rounded hover:bg-red-600"
+                  >
+                    移出
+                  </button>
+                </div>
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+
+      {/* 举手管理区域 */}
+      {handUpList.length > 0 && (
+        <div className="bg-white rounded-lg shadow p-4">
+          <h3 className="text-lg font-semibold mb-4 flex items-center">
+            <HandRaisedIcon className="w-5 h-5 mr-2 text-orange-500" />
+            举手请求 ({handUpList.length})
+          </h3>
+          
+          <div className="space-y-2">
+            {handUpList.map((request, index) => (
+              <div key={index} className="p-3 border rounded-lg bg-orange-50">
+                <div className="flex items-center justify-between mb-2">
+                  <span className="font-medium">{request.studentName || request.studentId}</span>
+                  <span className="text-xs text-gray-500">
+                    {new Date(request.timestamp).toLocaleTimeString()}
+                  </span>
+                </div>
+                {request.question && (
+                  <p className="text-sm text-gray-600 mb-2">问题:{request.question}</p>
+                )}
+                <button
+                  onClick={() => handleAnswerHandUp(request.studentId, request.studentName || request.studentId)}
+                  className="px-3 py-1 bg-green-500 text-white text-sm rounded hover:bg-green-600"
+                >
+                  应答
+                </button>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {/* 问题管理区域 */}
+      {questions.length > 0 && (
+        <div className="bg-white rounded-lg shadow p-4">
+          <h3 className="text-lg font-semibold mb-4 flex items-center">
+            <QuestionMarkCircleIcon className="w-5 h-5 mr-2 text-purple-500" />
+            学生提问 ({questions.length})
+          </h3>
+          
+          <div className="space-y-3">
+            {questions.map((question, index) => (
+              <div key={index} className="p-3 border rounded-lg bg-purple-50">
+                <div className="flex items-center justify-between mb-2">
+                  <span className="font-medium">{question.studentName}</span>
+                  <span className="text-xs text-gray-500">
+                    {new Date(question.timestamp).toLocaleTimeString()}
+                  </span>
+                </div>
+                <p className="text-sm text-gray-700">{question.question}</p>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+};

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

@@ -39,7 +39,8 @@ interface ImGroupMessage {
 enum InteractionAction {
   HandUp = 'hand_up',
   CancelHandUp = 'cancel_hand_up',
-  AnswerHandUp = 'answer_hand_up'
+  AnswerHandUp = 'answer_hand_up',
+  KickStudent = 'kick_student'
 }
 
 interface InteractionMessage {
@@ -160,6 +161,39 @@ export const useClassroom = ({ user }:{ user : User }) => {
 
     imGroupManager.current.on('memberchange', (groupId: string, memberCount: number, joinUsers: ImUser[], leaveUsers: ImUser[]) => {
       showMessage(`成员变更: 加入${joinUsers.length}人, 离开${leaveUsers.length}人`);
+      
+      // 更新学生列表
+      setStudents(prevStudents => {
+        let updatedStudents = [...prevStudents];
+        
+        // 处理离开的用户
+        updatedStudents = updatedStudents.filter(student =>
+          !leaveUsers.some(leaveUser => leaveUser.userId === student.id)
+        );
+        
+        // 处理新加入的用户(排除老师自己)
+        joinUsers.forEach(joinUser => {
+          if (joinUser.userId !== userId) { // 排除老师自己
+            const existingStudent = updatedStudents.find(s => s.id === joinUser.userId);
+            if (!existingStudent) {
+              try {
+                const userExtension = joinUser.userExtension ? JSON.parse(joinUser.userExtension) : {};
+                updatedStudents.push({
+                  id: joinUser.userId,
+                  name: userExtension.nickname || userExtension.username || joinUser.userId
+                });
+              } catch {
+                updatedStudents.push({
+                  id: joinUser.userId,
+                  name: joinUser.userId
+                });
+              }
+            }
+          }
+        });
+        
+        return updatedStudents;
+      });
     });
   };
 
@@ -237,6 +271,19 @@ export const useClassroom = ({ user }:{ user : User }) => {
         } catch (err) {
           console.error('解析应答消息失败', err);
         }
+      } else if (msg.type === 88894) { // 踢人消息
+        try {
+          const data = JSON.parse(msg.data) as InteractionMessage;
+          if (data.action === InteractionAction.KickStudent && data.studentId === userId) {
+            showMessage('您已被老师移出课堂');
+            // 学生被踢出后自动离开课堂
+            setTimeout(() => {
+              leaveClass();
+            }, 2000);
+          }
+        } catch (err) {
+          console.error('解析踢人消息失败', err);
+        }
       } else if (msg.type === 88888) { // 普通文本消息
         const sender = msg.sender;
         const userExtension = JSON.parse(sender?.userExtension || '{}') as User;
@@ -833,8 +880,19 @@ export const useClassroom = ({ user }:{ user : User }) => {
     if (!imGroupManager.current || !classId || role !== Role.Teacher) return;
     
     try {
-      await imGroupManager.current.leaveGroup(classId);
-      showToast('info', `已移出学生 ${studentId}`);
+      // 使用InteractionAction发送踢人消息
+      await imMessageManager.current?.sendGroupMessage({
+        groupId: classId,
+        data: JSON.stringify({
+          action: InteractionAction.KickStudent,
+          studentId: studentId,
+          timestamp: Date.now()
+        }),
+        type: 88894, // 使用新的消息类型表示踢人操作
+        level: HIGH,
+      });
+      
+      showToast('info', `已发送移出指令给学生 ${studentId}`);
     } catch (err: any) {
       setErrorMessage(`移出失败: ${err.message}`);
     }

+ 3 - 2
src/client/mobile/pages/ClassroomPage.tsx

@@ -5,6 +5,8 @@ import { Role, ClassStatus } from '@/client/mobile/components/Classroom/useClass
 import { ClassroomLayout } from '@/client/mobile/components/Classroom/ClassroomLayout';
 import { AuthLayout } from '@/client/mobile/components/Classroom/AuthLayout';
 import { ClassroomProvider, useClassroomContext } from "@/client/mobile/components/Classroom/ClassroomProvider";
+import { TeacherView } from '@/client/mobile/components/Classroom/TeacherView';
+import { StudentView } from '@/client/mobile/components/Classroom/StudentView';
 import { ToastContainer } from 'react-toastify';
 
 const RoleSelection = () => {
@@ -183,8 +185,7 @@ const Classroom = () => {
 
   return (
     <ClassroomLayout role={role}>
-      {/* {role === Role.Teacher ? <TeacherView /> : <StudentView />} */}
-      <></>
+      {role === Role.Teacher ? <TeacherView /> : <StudentView />}
     </ClassroomLayout>
   );
 };