This commit is contained in:
@@ -20,24 +20,72 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [onlyOnline, setOnlyOnline] = useState<boolean>(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||
const [zoomedStudent, setZoomedStudent] = useState<ExamRoomStudent | null>(null);
|
||||
const [pageSize, setPageSize] = useState<number | 'all'>(12);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const subscribedRef = useRef<Set<number>>(new Set());
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setZoomedStudent(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const studentIdsString = useMemo(
|
||||
() => students.map((s) => s.studentRkId).join(','),
|
||||
[students]
|
||||
);
|
||||
const onlineKey = useMemo(() => onlineIds.join(','), [onlineIds]);
|
||||
|
||||
const onlineIdsRef = useRef<number[]>(onlineIds);
|
||||
onlineIdsRef.current = 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 pagedStudents = useMemo(() => {
|
||||
if (pageSize === 'all') return filteredStudents;
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredStudents.slice(start, start + pageSize);
|
||||
}, [filteredStudents, currentPage, pageSize]);
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
if (pageSize === 'all') return 1;
|
||||
return Math.ceil(filteredStudents.length / pageSize) || 1;
|
||||
}, [filteredStudents, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) {
|
||||
setCurrentPage(totalPages);
|
||||
}
|
||||
}, [totalPages, currentPage]);
|
||||
|
||||
const visibleOnlineIds = useMemo(() => {
|
||||
return pagedStudents
|
||||
.map((s) => s.studentRkId)
|
||||
.filter((id) => onlineIds.includes(id));
|
||||
}, [pagedStudents, onlineIds]);
|
||||
|
||||
const visibleOnlineIdsRef = useRef<number[]>(visibleOnlineIds);
|
||||
visibleOnlineIdsRef.current = visibleOnlineIds;
|
||||
|
||||
const onlineKey = useMemo(() => visibleOnlineIds.join(','), [visibleOnlineIds]);
|
||||
|
||||
const syncSubscriptions = (ws: WebSocket) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
const currentOnlineIds = onlineIdsRef.current;
|
||||
const target = new Set(currentOnlineIds);
|
||||
const currentVisibleIds = visibleOnlineIdsRef.current;
|
||||
const target = new Set(currentVisibleIds);
|
||||
const prev = subscribedRef.current;
|
||||
|
||||
prev.forEach((id) => {
|
||||
@@ -46,7 +94,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
prev.delete(id);
|
||||
}
|
||||
});
|
||||
currentOnlineIds.forEach((id) => {
|
||||
currentVisibleIds.forEach((id) => {
|
||||
if (!prev.has(id)) {
|
||||
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id } }));
|
||||
prev.add(id);
|
||||
@@ -54,6 +102,11 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const ws = wsRef.current;
|
||||
if (ws) syncSubscriptions(ws);
|
||||
}, [onlineKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (students.length === 0) return;
|
||||
|
||||
@@ -144,11 +197,6 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
};
|
||||
}, [studentIdsString]);
|
||||
|
||||
useEffect(() => {
|
||||
const ws = wsRef.current;
|
||||
if (ws) syncSubscriptions(ws);
|
||||
}, [onlineKey]);
|
||||
|
||||
// Clean up frames when students are removed or go offline
|
||||
useEffect(() => {
|
||||
setScreenFrames((prev) => {
|
||||
@@ -177,17 +225,6 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
});
|
||||
}, [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({
|
||||
@@ -231,7 +268,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
<span className="search-icon">🔍</span>
|
||||
</div>
|
||||
|
||||
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||||
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem', color: 'var(--text-primary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyOnline}
|
||||
@@ -240,7 +277,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
<span>Chỉ hiện Online</span>
|
||||
</label>
|
||||
|
||||
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||||
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem', color: 'var(--text-primary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showWebcamOverlay}
|
||||
@@ -251,6 +288,33 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="grid-proctor-toolbar-right">
|
||||
<div className="grid-cols-selector" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Xem tối đa:</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setPageSize(val === 'all' ? 'all' : Number(val));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.2rem 0.4rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border-color)',
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value={12}>12 bạn</option>
|
||||
<option value={24}>24 bạn</option>
|
||||
<option value={48}>48 bạn</option>
|
||||
<option value="all">Tất cả</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid-cols-selector">
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Cột:</span>
|
||||
<button
|
||||
@@ -302,7 +366,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
padding: '1rem 0',
|
||||
}}
|
||||
>
|
||||
{filteredStudents.map((s) => {
|
||||
{pagedStudents.map((s) => {
|
||||
const isOnline = onlineIds.includes(s.studentRkId);
|
||||
const screenFrame = screenFrames[s.studentRkId];
|
||||
const webcamFrame = webcamFrames[s.studentRkId];
|
||||
@@ -325,7 +389,14 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
|
||||
<div className="grid-proctor-card-body">
|
||||
{isOnline ? (
|
||||
<div className="proctor-frame-container">
|
||||
<div
|
||||
className="proctor-frame-container cursor-zoom-in"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setZoomedStudent(s);
|
||||
}}
|
||||
style={{ cursor: 'zoom-in' }}
|
||||
>
|
||||
{screenFrame ? (
|
||||
<img
|
||||
src={screenFrame}
|
||||
@@ -393,6 +464,208 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div
|
||||
className="grid-proctor-pagination"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
padding: '1rem 0',
|
||||
borderTop: '1px solid var(--border-light)',
|
||||
marginTop: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
← Trang trước
|
||||
</button>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', fontWeight: 600 }}>
|
||||
Trang {currentPage} / {totalPages} (Tổng {filteredStudents.length} bạn)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
Trang sau →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom Modal Overlay (Rendered inside the container so it works in fullscreen mode) */}
|
||||
{zoomedStudent && (
|
||||
<div
|
||||
className="zoomed-proctor-overlay"
|
||||
onClick={() => setZoomedStudent(null)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(10, 15, 30, 0.9)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem',
|
||||
animation: 'fadeIn 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="zoomed-proctor-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '1000px',
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.4)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '90%',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '1rem 1.5rem',
|
||||
borderBottom: '1px solid var(--border-color)',
|
||||
backgroundColor: 'var(--bg-subtle)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: onlineIds.includes(zoomedStudent.studentRkId) ? 'var(--success)' : 'var(--danger)',
|
||||
boxShadow: onlineIds.includes(zoomedStudent.studentRkId) ? '0 0 8px var(--success)' : 'none',
|
||||
}}
|
||||
/>
|
||||
<h3 style={{ margin: 0, color: 'var(--text-primary)', fontSize: '1.1rem', fontWeight: 600 }}>
|
||||
{zoomedStudent.fullName}
|
||||
</h3>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)',
|
||||
backgroundColor: 'var(--bg-subtle)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{zoomedStudent.studentCode}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setZoomedStudent(null)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '1.5rem',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#000',
|
||||
position: 'relative',
|
||||
minHeight: '400px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{screenFrames[zoomedStudent.studentRkId] ? (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<img
|
||||
src={screenFrames[zoomedStudent.studentRkId]}
|
||||
alt="Zoomed Screen"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '65vh',
|
||||
objectFit: 'contain',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
{showWebcamOverlay && webcamFrames[zoomedStudent.studentRkId] && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
width: '240px',
|
||||
aspectRatio: '4/3',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #ffffff',
|
||||
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.4)',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={webcamFrames[zoomedStudent.studentRkId]}
|
||||
alt="Zoomed Webcam"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#8e8e9e', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||
<div className="sync-spinner" style={{ width: '32px', height: '32px', borderWidth: '3px', marginBottom: '0.5rem' }} />
|
||||
<span>Đang tải màn hình...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.cursor-zoom-in {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.cursor-zoom-in:hover {
|
||||
transform: scale(1.015);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5661,7 +5661,7 @@ input:checked + .slider:before {
|
||||
.grid-proctor-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
background: var(--bg-card, #ffffff);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user