Bladeren bron

✨ feat(classroom): 实现课堂角色功能按钮和管理面板

- 添加教师端控制按钮:课堂控制、学生管理、举手管理、问题管理
- 添加学生端交互按钮:举手提问、发送问题
- 实现角色特定功能面板,根据教师/学生角色显示不同功能
- 新增6个专用按钮组件,优化课堂交互体验
- 实现举手请求管理面板,支持教师应答学生举手请求
- 实现学生问题管理面板,集中展示学生发送的问题
yourname 6 maanden geleden
bovenliggende
commit
5b55aef4f2

+ 26 - 3
src/client/mobile/components/Classroom/ClassroomLayout.tsx

@@ -12,6 +12,12 @@ import {
 import { Button } from '@/client/components/ui/button';
 import { Textarea } from '@/client/components/ui/textarea';
 import { Card, CardContent } from '@/client/components/ui/card';
+import { TeacherClassControlButton } from './TeacherClassControlButton';
+import { TeacherStudentManagementButton } from './TeacherStudentManagementButton';
+import { TeacherHandUpManagementButton } from './TeacherHandUpManagementButton';
+import { TeacherQuestionManagementButton } from './TeacherQuestionManagementButton';
+import { StudentHandUpButton } from './StudentHandUpButton';
+import { StudentQuestionButton } from './StudentQuestionButton';
 
 interface ClassroomLayoutProps {
   children: ReactNode;
@@ -153,6 +159,23 @@ export const ClassroomLayout = ({ children, role }: ClassroomLayoutProps) => {
                   <ClipboardDocumentIcon className="w-4 h-4" />
                 </Button>
               )}
+
+              {/* 角色特定按钮 */}
+              {role === Role.Teacher && (
+                <>
+                  <TeacherClassControlButton />
+                  <TeacherStudentManagementButton />
+                  <TeacherHandUpManagementButton />
+                  <TeacherQuestionManagementButton />
+                </>
+              )}
+
+              {role === Role.Student && (
+                <>
+                  <StudentHandUpButton />
+                  <StudentQuestionButton />
+                </>
+              )}
             </div>
 
             {showShareLink && shareLink && (
@@ -179,10 +202,10 @@ export const ClassroomLayout = ({ children, role }: ClassroomLayoutProps) => {
               </Card>
             )}
 
-            {/* 角色特定内容 */}
-            <div className="flex-1 overflow-y-auto">
+            {/* 角色特定内容 - 保留原有功能 */}
+            {/* <div className="flex-1 overflow-y-auto">
               {children}
-            </div>
+            </div> */}
           </div>
 
           {/* 消息输入框 */}

+ 78 - 0
src/client/mobile/components/Classroom/StudentHandUpButton.tsx

@@ -0,0 +1,78 @@
+import React, { useState } from 'react';
+import { useClassroomContext } from './ClassroomProvider';
+import { HandRaisedIcon, XMarkIcon } from '@heroicons/react/24/outline';
+import { Button } from '@/client/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/client/components/ui/card';
+import { Textarea } from '@/client/components/ui/textarea';
+
+export const StudentHandUpButton: React.FC = () => {
+  const { handUp, classStatus } = useClassroomContext();
+  const [showPanel, setShowPanel] = useState(false);
+  const [questionText, setQuestionText] = useState('');
+
+  const handleHandUp = async () => {
+    await handUp(questionText || undefined);
+    setQuestionText('');
+    setShowPanel(false);
+  };
+
+  return (
+    <div className="relative">
+      <Button
+        type="button"
+        onClick={() => setShowPanel(!showPanel)}
+        disabled={classStatus === 'ended'}
+        className={`p-2 rounded-full ${classStatus === 'ended' ? 'bg-gray-500' : 'bg-orange-500'} text-white`}
+        title={classStatus === 'ended' ? '课堂已结束' : '举手提问'}
+        size="icon"
+        variant="ghost"
+      >
+        <HandRaisedIcon className="w-4 h-4" />
+      </Button>
+
+      {showPanel && (
+        <div className="absolute bottom-full right-0 mb-2 w-80 bg-white shadow-lg rounded-lg z-50">
+          <Card className="border-0">
+            <CardHeader className="p-3 flex flex-row items-center justify-between">
+              <CardTitle className="text-sm flex items-center">
+                <HandRaisedIcon className="w-4 h-4 mr-2 text-orange-500" />
+                举手提问
+              </CardTitle>
+              <Button
+                type="button"
+                onClick={() => setShowPanel(false)}
+                className="p-1 rounded-full"
+                size="icon"
+                variant="ghost"
+              >
+                <XMarkIcon className="w-4 h-4" />
+              </Button>
+            </CardHeader>
+            <CardContent className="p-3 space-y-3">
+              <Textarea
+                value={questionText}
+                onChange={(e) => setQuestionText(e.target.value)}
+                placeholder="请输入您的问题(可选)..."
+                className="w-full text-sm"
+                rows={3}
+              />
+              
+              <Button
+                onClick={handleHandUp}
+                disabled={classStatus === 'ended'}
+                variant={classStatus === 'ended' ? "secondary" : "default"}
+                className="w-full bg-orange-500 text-white hover:bg-orange-600 text-sm"
+              >
+                举手提问
+              </Button>
+              
+              <p className="text-xs text-gray-500">
+                点击举手后,老师会收到通知并可以应答您的问题
+              </p>
+            </CardContent>
+          </Card>
+        </div>
+      )}
+    </div>
+  );
+};

+ 78 - 0
src/client/mobile/components/Classroom/StudentQuestionButton.tsx

@@ -0,0 +1,78 @@
+import React, { useState } from 'react';
+import { useClassroomContext } from './ClassroomProvider';
+import { QuestionMarkCircleIcon, XMarkIcon } from '@heroicons/react/24/outline';
+import { Button } from '@/client/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/client/components/ui/card';
+import { Textarea } from '@/client/components/ui/textarea';
+
+export const StudentQuestionButton: React.FC = () => {
+  const { sendQuestion, classStatus } = useClassroomContext();
+  const [showPanel, setShowPanel] = useState(false);
+  const [questionText, setQuestionText] = useState('');
+
+  const handleSendQuestion = async () => {
+    if (!questionText.trim()) {
+      return;
+    }
+    
+    await sendQuestion(questionText);
+    setQuestionText('');
+    setShowPanel(false);
+  };
+
+  return (
+    <div className="relative">
+      <Button
+        type="button"
+        onClick={() => setShowPanel(!showPanel)}
+        disabled={classStatus === 'ended'}
+        className={`p-2 rounded-full ${classStatus === 'ended' ? 'bg-gray-500' : 'bg-purple-500'} text-white`}
+        title={classStatus === 'ended' ? '课堂已结束' : '发送问题'}
+        size="icon"
+        variant="ghost"
+      >
+        <QuestionMarkCircleIcon className="w-4 h-4" />
+      </Button>
+
+      {showPanel && (
+        <div className="absolute bottom-full right-0 mb-2 w-80 bg-white shadow-lg rounded-lg z-50">
+          <Card className="border-0">
+            <CardHeader className="p-3 flex flex-row items-center justify-between">
+              <CardTitle className="text-sm flex items-center">
+                <QuestionMarkCircleIcon className="w-4 h-4 mr-2 text-purple-500" />
+                发送问题
+              </CardTitle>
+              <Button
+                type="button"
+                onClick={() => setShowPanel(false)}
+                className="p-1 rounded-full"
+                size="icon"
+                variant="ghost"
+              >
+                <XMarkIcon className="w-4 h-4" />
+              </Button>
+            </CardHeader>
+            <CardContent className="p-3 space-y-3">
+              <Textarea
+                value={questionText}
+                onChange={(e) => setQuestionText(e.target.value)}
+                placeholder="请输入您的问题..."
+                className="w-full text-sm"
+                rows={3}
+              />
+              
+              <Button
+                onClick={handleSendQuestion}
+                disabled={classStatus === 'ended' || !questionText.trim()}
+                variant={classStatus === 'ended' || !questionText.trim() ? "secondary" : "default"}
+                className="w-full bg-purple-500 text-white hover:bg-purple-600 text-sm"
+              >
+                发送问题
+              </Button>
+            </CardContent>
+          </Card>
+        </div>
+      )}
+    </div>
+  );
+};

+ 28 - 0
src/client/mobile/components/Classroom/TeacherClassControlButton.tsx

@@ -0,0 +1,28 @@
+import React from 'react';
+import { useClassroomContext } from './ClassroomProvider';
+import { ClassStatus } from './useClassroom';
+import { StopIcon } from '@heroicons/react/24/outline';
+import { Button } from '@/client/components/ui/button';
+
+export const TeacherClassControlButton: React.FC = () => {
+  const { endClass, classStatus } = useClassroomContext();
+
+  const handleEndClass = async () => {
+    if (window.confirm('确定要结束课堂吗?所有学生将被断开连接。')) {
+      await endClass();
+    }
+  };
+
+  return (
+    <Button
+      type="button"
+      onClick={handleEndClass}
+      className={`p-2 rounded-full ${classStatus === ClassStatus.ENDED ? 'bg-gray-500' : 'bg-red-500'} text-white`}
+      title={classStatus === ClassStatus.ENDED ? '课堂已结束' : '结束课堂'}
+      size="icon"
+      variant="ghost"
+    >
+      <StopIcon className="w-4 h-4" />
+    </Button>
+  );
+};

+ 83 - 0
src/client/mobile/components/Classroom/TeacherHandUpManagementButton.tsx

@@ -0,0 +1,83 @@
+import React, { useState } from 'react';
+import { useClassroomContext } from './ClassroomProvider';
+import { HandRaisedIcon, XMarkIcon } from '@heroicons/react/24/outline';
+import { Button } from '@/client/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/client/components/ui/card';
+
+export const TeacherHandUpManagementButton: React.FC = () => {
+  const { handUpList, answerHandUp } = useClassroomContext();
+  const [showPanel, setShowPanel] = useState(false);
+
+  const handleAnswerHandUp = (studentId: string, studentName: string) => {
+    answerHandUp(studentId);
+  };
+
+  if (handUpList.length === 0) {
+    return null;
+  }
+
+  return (
+    <div className="relative">
+      <Button
+        type="button"
+        onClick={() => setShowPanel(!showPanel)}
+        className="p-2 rounded-full bg-orange-500 text-white relative"
+        title={`举手请求 (${handUpList.length})`}
+        size="icon"
+        variant="ghost"
+      >
+        <HandRaisedIcon className="w-4 h-4" />
+        <span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
+          {handUpList.length}
+        </span>
+      </Button>
+
+      {showPanel && (
+        <div className="absolute bottom-full right-0 mb-2 w-80 bg-white shadow-lg rounded-lg z-50">
+          <Card className="border-0">
+            <CardHeader className="p-3 flex flex-row items-center justify-between">
+              <CardTitle className="text-sm flex items-center">
+                <HandRaisedIcon className="w-4 h-4 mr-2 text-orange-500" />
+                举手请求 ({handUpList.length})
+              </CardTitle>
+              <Button
+                type="button"
+                onClick={() => setShowPanel(false)}
+                className="p-1 rounded-full"
+                size="icon"
+                variant="ghost"
+              >
+                <XMarkIcon className="w-4 h-4" />
+              </Button>
+            </CardHeader>
+            <CardContent className="p-3 max-h-60 overflow-y-auto">
+              <div className="space-y-2">
+                {handUpList.map((request, index) => (
+                  <div key={index} className="p-3 border rounded-lg bg-orange-50 text-sm">
+                    <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-gray-600 mb-2">问题:{request.question}</p>
+                    )}
+                    <Button
+                      onClick={() => handleAnswerHandUp(request.studentId, request.studentName || request.studentId)}
+                      variant="default"
+                      size="sm"
+                      className="text-sm bg-green-500 text-white hover:bg-green-600 w-full"
+                    >
+                      应答
+                    </Button>
+                  </div>
+                ))}
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
+    </div>
+  );
+};

+ 69 - 0
src/client/mobile/components/Classroom/TeacherQuestionManagementButton.tsx

@@ -0,0 +1,69 @@
+import React, { useState } from 'react';
+import { useClassroomContext } from './ClassroomProvider';
+import { QuestionMarkCircleIcon, XMarkIcon } from '@heroicons/react/24/outline';
+import { Button } from '@/client/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/client/components/ui/card';
+
+export const TeacherQuestionManagementButton: React.FC = () => {
+  const { questions } = useClassroomContext();
+  const [showPanel, setShowPanel] = useState(false);
+
+  if (questions.length === 0) {
+    return null;
+  }
+
+  return (
+    <div className="relative">
+      <Button
+        type="button"
+        onClick={() => setShowPanel(!showPanel)}
+        className="p-2 rounded-full bg-purple-500 text-white relative"
+        title={`学生提问 (${questions.length})`}
+        size="icon"
+        variant="ghost"
+      >
+        <QuestionMarkCircleIcon className="w-4 h-4" />
+        <span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
+          {questions.length}
+        </span>
+      </Button>
+
+      {showPanel && (
+        <div className="absolute bottom-full right-0 mb-2 w-80 bg-white shadow-lg rounded-lg z-50">
+          <Card className="border-0">
+            <CardHeader className="p-3 flex flex-row items-center justify-between">
+              <CardTitle className="text-sm flex items-center">
+                <QuestionMarkCircleIcon className="w-4 h-4 mr-2 text-purple-500" />
+                学生提问 ({questions.length})
+              </CardTitle>
+              <Button
+                type="button"
+                onClick={() => setShowPanel(false)}
+                className="p-1 rounded-full"
+                size="icon"
+                variant="ghost"
+              >
+                <XMarkIcon className="w-4 h-4" />
+              </Button>
+            </CardHeader>
+            <CardContent className="p-3 max-h-60 overflow-y-auto">
+              <div className="space-y-3">
+                {questions.map((question, index) => (
+                  <div key={index} className="p-3 border rounded-lg bg-purple-50 text-sm">
+                    <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-gray-700">{question.question}</p>
+                  </div>
+                ))}
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
+    </div>
+  );
+};

+ 109 - 0
src/client/mobile/components/Classroom/TeacherStudentManagementButton.tsx

@@ -0,0 +1,109 @@
+import React, { useState } from 'react';
+import { useClassroomContext } from './ClassroomProvider';
+import { UserGroupIcon, XMarkIcon } from '@heroicons/react/24/outline';
+import { Button } from '@/client/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/client/components/ui/card';
+
+export const TeacherStudentManagementButton: React.FC = () => {
+  const { students, toggleMuteMember, kickStudent } = useClassroomContext();
+  const [showPanel, setShowPanel] = useState(false);
+
+  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 handleKickStudent = (studentId: string, studentName: string) => {
+    if (window.confirm(`确定要移出 ${studentName} 吗?`)) {
+      kickStudent(studentId);
+    }
+  };
+
+  return (
+    <div className="relative">
+      <Button
+        type="button"
+        onClick={() => setShowPanel(!showPanel)}
+        className="p-2 rounded-full bg-blue-500 text-white relative"
+        title={`学生管理 (${students.length})`}
+        size="icon"
+        variant="ghost"
+      >
+        <UserGroupIcon className="w-4 h-4" />
+        {students.length > 0 && (
+          <span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
+            {students.length}
+          </span>
+        )}
+      </Button>
+
+      {showPanel && (
+        <div className="absolute bottom-full right-0 mb-2 w-80 bg-white shadow-lg rounded-lg z-50">
+          <Card className="border-0">
+            <CardHeader className="p-3 flex flex-row items-center justify-between">
+              <CardTitle className="text-sm flex items-center">
+                <UserGroupIcon className="w-4 h-4 mr-2 text-blue-500" />
+                学生管理 ({students.length})
+              </CardTitle>
+              <Button
+                type="button"
+                onClick={() => setShowPanel(false)}
+                className="p-1 rounded-full"
+                size="icon"
+                variant="ghost"
+              >
+                <XMarkIcon className="w-4 h-4" />
+              </Button>
+            </CardHeader>
+            <CardContent className="p-3 max-h-60 overflow-y-auto">
+              {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 text-sm">
+                      <span>{student.name}</span>
+                      <div className="flex gap-1">
+                        <Button
+                          onClick={() => handleMuteStudent(student.id, student.name)}
+                          variant="outline"
+                          size="sm"
+                          className="text-xs bg-yellow-100 text-yellow-700 hover:bg-yellow-200 border-yellow-300"
+                        >
+                          静音
+                        </Button>
+                        <Button
+                          onClick={() => handleUnmuteStudent(student.id, student.name)}
+                          variant="outline"
+                          size="sm"
+                          className="text-xs bg-green-100 text-green-700 hover:bg-green-200 border-green-300"
+                        >
+                          取消
+                        </Button>
+                        <Button
+                          onClick={() => handleKickStudent(student.id, student.name)}
+                          variant="outline"
+                          size="sm"
+                          className="text-xs bg-red-100 text-red-700 hover:bg-red-200 border-red-300"
+                        >
+                          移出
+                        </Button>
+                      </div>
+                    </div>
+                  ))}
+                </div>
+              )}
+            </CardContent>
+          </Card>
+        </div>
+      )}
+    </div>
+  );
+};