From 9a994da7f8ced73236c2cbac4e97ee6cc3808a9b Mon Sep 17 00:00:00 2001 From: PhuocNTB Date: Fri, 3 Jul 2026 06:46:17 +0700 Subject: [PATCH] fix xem nhieu video --- management/src/components/ExamGridProctor.tsx | 338 ++++++++++++++++++ .../src/components/ExamRoomWorkspace.tsx | 18 +- management/src/index.css | 290 +++++++++++++++ server/internal/websocket/websocket.go | 5 + 4 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 management/src/components/ExamGridProctor.tsx diff --git a/management/src/components/ExamGridProctor.tsx b/management/src/components/ExamGridProctor.tsx new file mode 100644 index 0000000..0c1c301 --- /dev/null +++ b/management/src/components/ExamGridProctor.tsx @@ -0,0 +1,338 @@ +import React, { useEffect, useState, useRef, useMemo } from 'react'; +import { getWsUrl, type ExamRoomStudent } from '../api'; +import { openStaffChat } from '../chatEvents'; + +interface ExamGridProctorProps { + students: ExamRoomStudent[]; + onlineIds: number[]; + onSelectStudent: (student: ExamRoomStudent) => void; +} + +export const ExamGridProctor: React.FC = ({ + students, + onlineIds, + onSelectStudent, +}) => { + const [screenFrames, setScreenFrames] = useState>({}); + const [webcamFrames, setWebcamFrames] = useState>({}); + const [gridCols, setGridCols] = useState(3); + const [showWebcamOverlay, setShowWebcamOverlay] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [onlyOnline, setOnlyOnline] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + + const wsRef = useRef(null); + const gridContainerRef = useRef(null); + + const studentIdsString = useMemo( + () => students.map((s) => s.studentRkId).join(','), + [students] + ); + + useEffect(() => { + if (students.length === 0) return; + + const wsUrl = getWsUrl('/ws?role=teacher'); + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + students.forEach((s) => { + ws.send( + JSON.stringify({ + event: 'teacher:subscribe', + data: { studentId: s.studentRkId }, + }) + ); + }); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.event === 'teacher:screenshot-stream-frame') { + const { studentId, imageBuffer } = msg.data; + setScreenFrames((prev) => ({ ...prev, [studentId]: imageBuffer })); + } else if (msg.event === 'teacher:webcam-stream-frame') { + const { studentId, imageBuffer } = msg.data; + setWebcamFrames((prev) => ({ ...prev, [studentId]: imageBuffer })); + } else if (msg.event === 'teacher:stream-stopped') { + const { studentId } = msg.data; + setScreenFrames((prev) => { + const next = { ...prev }; + delete next[studentId]; + return next; + }); + setWebcamFrames((prev) => { + const next = { ...prev }; + delete next[studentId]; + return next; + }); + } + } catch (err) { + console.error('Error parsing WS message in grid view:', err); + } + }; + + return () => { + if (ws.readyState === WebSocket.OPEN) { + students.forEach((s) => { + ws.send( + JSON.stringify({ + event: 'teacher:unsubscribe', + data: { studentId: s.studentRkId }, + }) + ); + }); + } + ws.close(); + }; + }, [studentIdsString]); + + // Clean up frames when students are removed or go offline + useEffect(() => { + setScreenFrames((prev) => { + const next = { ...prev }; + let changed = false; + Object.keys(next).forEach((idStr) => { + const id = Number(idStr); + if (!onlineIds.includes(id)) { + delete next[id]; + changed = true; + } + }); + return changed ? next : prev; + }); + setWebcamFrames((prev) => { + const next = { ...prev }; + let changed = false; + Object.keys(next).forEach((idStr) => { + const id = Number(idStr); + if (!onlineIds.includes(id)) { + delete next[id]; + changed = true; + } + }); + return changed ? next : prev; + }); + }, [onlineIds]); + + const filteredStudents = useMemo(() => { + return students.filter((s) => { + const matchesSearch = + s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) || + s.studentCode.toLowerCase().includes(searchQuery.toLowerCase()); + const isOnline = onlineIds.includes(s.studentRkId); + const matchesOnlineFilter = !onlyOnline || isOnline; + return matchesSearch && matchesOnlineFilter; + }); + }, [students, onlineIds, searchQuery, onlyOnline]); + + const handleOpenChat = (s: ExamRoomStudent, e: React.MouseEvent) => { + e.stopPropagation(); + openStaffChat({ + studentRkId: s.studentRkId, + fullName: s.fullName, + studentCode: s.studentCode, + email: s.email, + }); + }; + + useEffect(() => { + const handleFsChange = () => { + setIsFullscreen(document.fullscreenElement === gridContainerRef.current); + }; + document.addEventListener('fullscreenchange', handleFsChange); + return () => document.removeEventListener('fullscreenchange', handleFsChange); + }, []); + + const toggleFullscreen = () => { + const el = gridContainerRef.current; + if (!el) return; + if (document.fullscreenElement === el) { + document.exitFullscreen().catch(console.error); + } else { + el.requestFullscreen().catch(console.error); + } + }; + + return ( +
+
+
+
+ setSearchQuery(e.target.value)} + /> + 🔍 +
+ + + + +
+ +
+
+ Cột: + + + + +
+ + +
+
+ +
+ {filteredStudents.map((s) => { + const isOnline = onlineIds.includes(s.studentRkId); + const screenFrame = screenFrames[s.studentRkId]; + const webcamFrame = webcamFrames[s.studentRkId]; + + return ( +
onSelectStudent(s)} + > +
+
+ + + {s.fullName} + +
+ {s.studentCode} +
+ +
+ {isOnline ? ( +
+ {screenFrame ? ( + {`Màn + ) : ( +
+
+ Đang kết nối màn hình... +
+ )} + + {showWebcamOverlay && webcamFrame && ( +
+ {`Webcam +
+ )} +
+ ) : ( +
+ 📴 + Ngoại tuyến (Offline) +
+ )} +
+ +
e.stopPropagation()}> + + {s.paperTitle ? `Đề: ${s.paperTitle}` : 'Chưa gán đề'} + +
+ + +
+
+
+ ); + })} + + {filteredStudents.length === 0 && ( +
+ 🔍 +

Không tìm thấy sinh viên nào.

+
+ )} +
+
+ ); +}; diff --git a/management/src/components/ExamRoomWorkspace.tsx b/management/src/components/ExamRoomWorkspace.tsx index 9200d60..317c0b6 100644 --- a/management/src/components/ExamRoomWorkspace.tsx +++ b/management/src/components/ExamRoomWorkspace.tsx @@ -19,6 +19,7 @@ import { NavHistoryBar } from './NavHistoryBar'; import { StudentAvatar } from './StudentAvatar'; import { StudentDetailModal } from './StudentDetailModal'; import { fmtTime, localInputToISO, toLocalInput } from './ExamsTab'; +import { ExamGridProctor } from './ExamGridProctor'; const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])]; @@ -87,7 +88,7 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { const [onlineIds, setOnlineIds] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [selectedStudent, setSelectedStudent] = useState(null); - const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions'>('roster'); + const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions' | 'grid'>('roster'); const [configOpen, setConfigOpen] = useState(false); const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info'); const [papersModalOpen, setPapersModalOpen] = useState(false); @@ -858,6 +859,13 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { > Sơ đồ sinh viên +
- {activeSubTab !== 'submissions' && ( + {activeSubTab !== 'submissions' && activeSubTab !== 'grid' && (
= ({ examId, onBack }) => { })}
) + ) : activeSubTab === 'grid' ? ( + setSelectedStudent(toStudentItem(s))} + /> ) : activeSubTab === 'detail' ? (
diff --git a/management/src/index.css b/management/src/index.css index b7c0d10..6dfcb34 100644 --- a/management/src/index.css +++ b/management/src/index.css @@ -5105,3 +5105,293 @@ input:checked + .slider:before { grid-template-columns: 1fr; } } + +/* ── Grid Proctoring (Net Cafe / Surveillance Cam Mode) ── */ +.grid-proctor-container { + display: flex; + flex-direction: column; + height: 100%; + background: var(--bg-card, #ffffff); + border-radius: var(--radius-md); + padding: 1.25rem; + border: 1px solid var(--border-color); + box-shadow: var(--shadow-sm); + transition: background 0.3s; +} + +.grid-proctor-container:fullscreen { + padding: 2rem; + background: #121214; + overflow-y: auto; +} + +.grid-proctor-container:fullscreen .grid-proctor-card { + background: #1e1e24; + border-color: #2e2e38; +} + +.grid-proctor-container:fullscreen .grid-proctor-card-header { + border-color: #2e2e38; +} + +.grid-proctor-container:fullscreen .student-name { + color: #e4e4eb; +} + +.grid-proctor-container:fullscreen .grid-proctor-card-footer { + border-color: #2e2e38; + color: #a0a0b0; +} + +.grid-proctor-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-light); +} + +.grid-proctor-toolbar-left { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 1.25rem; +} + +.grid-proctor-toolbar-right { + display: flex; + align-items: center; + gap: 1rem; +} + +.grid-cols-selector { + display: flex; + align-items: center; + gap: 0.25rem; + background: var(--bg-subtle); + padding: 0.2rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); +} + +.grid-cols-selector button { + padding: 0.25rem 0.5rem; + border: none; + font-size: 0.75rem; + font-weight: 700; + cursor: pointer; + background: transparent; + border-radius: 4px; +} + +.grid-cols-selector button.active { + background: var(--accent); + color: #ffffff; +} + +.grid-proctor-layout { + /* Columns configured inline */ + transition: all 0.3s ease; +} + +.grid-proctor-card { + display: flex; + flex-direction: column; + background: #ffffff; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: var(--shadow-sm); + transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s; + cursor: pointer; +} + +.grid-proctor-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + border-color: var(--accent); +} + +.grid-proctor-card.online { + border-color: rgba(13, 159, 110, 0.2); +} + +.grid-proctor-card.online:hover { + border-color: var(--success); +} + +.grid-proctor-card.offline { + opacity: 0.75; + filter: grayscale(0.2); +} + +.grid-proctor-card.submitted { + border-left: 4px solid var(--success); +} + +.grid-proctor-card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.65rem 0.85rem; + background: var(--bg-subtle); + border-bottom: 1px solid var(--border-light); + font-size: 0.85rem; + font-weight: 600; +} + +.student-info-left { + display: flex; + align-items: center; + gap: 0.5rem; + overflow: hidden; + white-space: nowrap; +} + +.student-name { + color: var(--text-primary); + text-overflow: ellipsis; + overflow: hidden; + font-weight: 700; +} + +.student-code { + font-family: monospace; + font-weight: 600; + color: var(--accent); + font-size: 0.8rem; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.status-dot.online { + background: var(--success); + box-shadow: 0 0 0 2px rgba(13, 159, 110, 0.2); + animation: pulse-online 1.8s infinite ease-in-out; +} + +.status-dot.offline { + background: var(--text-muted); +} + +.grid-proctor-card-body { + position: relative; + background: #141416; + aspect-ratio: 16 / 9; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.proctor-frame-container { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.proctor-screen-image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + user-select: none; +} + +.proctor-webcam-overlay { + position: absolute; + bottom: 6px; + right: 6px; + width: 28%; + aspect-ratio: 4 / 3; + border: 1.5px solid #ffffff; + border-radius: 4px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + background: #000; + z-index: 10; +} + +.proctor-webcam-image { + width: 100%; + height: 100%; + object-fit: cover; + user-select: none; +} + +.proctor-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + color: #8e8e9e; + font-size: 0.8rem; + padding: 1rem; + text-align: center; + width: 100%; + height: 100%; +} + +.proctor-placeholder.streaming { + background: #18181b; +} + +.proctor-placeholder.offline { + background: #202023; + color: #71717a; +} + +.proctor-placeholder .icon { + font-size: 1.5rem; + margin-bottom: 0.25rem; +} + +.grid-proctor-card-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.55rem 0.85rem; + background: var(--bg-subtle); + border-top: 1px solid var(--border-light); + font-size: 0.78rem; + color: var(--text-secondary); +} + +.assigned-paper { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 50%; + font-style: italic; +} + +.footer-actions { + display: flex; + gap: 0.35rem; +} + +.grid-proctor-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 250px; + color: var(--text-muted); + text-align: center; +} + +.grid-proctor-empty span { + font-size: 2.5rem; + margin-bottom: 0.75rem; +} + diff --git a/server/internal/websocket/websocket.go b/server/internal/websocket/websocket.go index 49d32f3..48af6a5 100644 --- a/server/internal/websocket/websocket.go +++ b/server/internal/websocket/websocket.go @@ -106,6 +106,11 @@ func (h *WsHub) Register(c *SocketClient) { if c.Role == "student" { h.students[c.StudentID] = c log.Printf("[WS] Student %d registered (Address: %s, Class: %d)", c.StudentID, c.Addr, c.ClassID) + if subs, exists := h.subscribers[c.StudentID]; exists && len(subs) > 0 { + _ = c.Conn.WriteJSON(SocketMsg{Event: "start_screenshot_stream"}) + _ = c.Conn.WriteJSON(SocketMsg{Event: "start_webcam_stream"}) + log.Printf("[WS] Student %d has active subscribers. Sent start stream commands.", c.StudentID) + } } else if c.Role == "teacher" { h.teachers[c.Addr] = c if c.StaffID > 0 {