fix xem nhieu video
All checks were successful
Deploy on Master Change / deploy (push) Successful in 47s
All checks were successful
Deploy on Master Change / deploy (push) Successful in 47s
This commit is contained in:
338
management/src/components/ExamGridProctor.tsx
Normal file
338
management/src/components/ExamGridProctor.tsx
Normal file
@@ -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<ExamGridProctorProps> = ({
|
||||
students,
|
||||
onlineIds,
|
||||
onSelectStudent,
|
||||
}) => {
|
||||
const [screenFrames, setScreenFrames] = useState<Record<number, string>>({});
|
||||
const [webcamFrames, setWebcamFrames] = useState<Record<number, string>>({});
|
||||
const [gridCols, setGridCols] = useState<number>(3);
|
||||
const [showWebcamOverlay, setShowWebcamOverlay] = useState<boolean>(true);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [onlyOnline, setOnlyOnline] = useState<boolean>(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const gridContainerRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="grid-proctor-container" ref={gridContainerRef}>
|
||||
<div className="grid-proctor-toolbar">
|
||||
<div className="grid-proctor-toolbar-left">
|
||||
<div className="search-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Lọc sinh viên..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<span className="search-icon">🔍</span>
|
||||
</div>
|
||||
|
||||
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyOnline}
|
||||
onChange={(e) => setOnlyOnline(e.target.checked)}
|
||||
/>
|
||||
<span>Chỉ hiện Online</span>
|
||||
</label>
|
||||
|
||||
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showWebcamOverlay}
|
||||
onChange={(e) => setShowWebcamOverlay(e.target.checked)}
|
||||
/>
|
||||
<span>Đè webcam góc màn hình</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid-proctor-toolbar-right">
|
||||
<div className="grid-cols-selector">
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Cột:</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary btn-xs ${gridCols === 2 ? 'active' : ''}`}
|
||||
onClick={() => setGridCols(2)}
|
||||
>
|
||||
2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary btn-xs ${gridCols === 3 ? 'active' : ''}`}
|
||||
onClick={() => setGridCols(3)}
|
||||
>
|
||||
3
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary btn-xs ${gridCols === 4 ? 'active' : ''}`}
|
||||
onClick={() => setGridCols(4)}
|
||||
>
|
||||
4
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary btn-xs ${gridCols === 6 ? 'active' : ''}`}
|
||||
onClick={() => setGridCols(6)}
|
||||
>
|
||||
6
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
{isFullscreen ? '🖥️ Thu nhỏ' : '🖥️ Toàn màn hình'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid-proctor-layout`}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
|
||||
gap: '1rem',
|
||||
padding: '1rem 0',
|
||||
}}
|
||||
>
|
||||
{filteredStudents.map((s) => {
|
||||
const isOnline = onlineIds.includes(s.studentRkId);
|
||||
const screenFrame = screenFrames[s.studentRkId];
|
||||
const webcamFrame = webcamFrames[s.studentRkId];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`grid-proctor-card ${isOnline ? 'online' : 'offline'} ${s.submitted ? 'submitted' : ''}`}
|
||||
onClick={() => onSelectStudent(s)}
|
||||
>
|
||||
<div className="grid-proctor-card-header">
|
||||
<div className="student-info-left">
|
||||
<span className={`status-dot ${isOnline ? 'online' : 'offline'}`} />
|
||||
<span className="student-name" title={s.fullName}>
|
||||
{s.fullName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="student-code">{s.studentCode}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid-proctor-card-body">
|
||||
{isOnline ? (
|
||||
<div className="proctor-frame-container">
|
||||
{screenFrame ? (
|
||||
<img
|
||||
src={screenFrame}
|
||||
alt={`Màn hình ${s.fullName}`}
|
||||
className="proctor-screen-image"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="proctor-placeholder streaming">
|
||||
<div className="sync-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px', marginBottom: '0.5rem' }} />
|
||||
<span>Đang kết nối màn hình...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWebcamOverlay && webcamFrame && (
|
||||
<div className="proctor-webcam-overlay">
|
||||
<img
|
||||
src={webcamFrame}
|
||||
alt={`Webcam ${s.fullName}`}
|
||||
className="proctor-webcam-image"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="proctor-placeholder offline">
|
||||
<span className="icon">📴</span>
|
||||
<span>Ngoại tuyến (Offline)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid-proctor-card-footer" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="assigned-paper" title={s.paperTitle || 'Chưa gán đề'}>
|
||||
{s.paperTitle ? `Đề: ${s.paperTitle}` : 'Chưa gán đề'}
|
||||
</span>
|
||||
<div className="footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-primary"
|
||||
onClick={(e) => handleOpenChat(s, e)}
|
||||
title="Nhắn tin cho sinh viên"
|
||||
>
|
||||
💬 Nhắn tin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
onClick={() => onSelectStudent(s)}
|
||||
title="Xem chi tiết giám sát"
|
||||
>
|
||||
🔍 Giám sát
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredStudents.length === 0 && (
|
||||
<div className="grid-proctor-empty" style={{ gridColumn: '1 / -1' }}>
|
||||
<span>🔍</span>
|
||||
<p>Không tìm thấy sinh viên nào.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user