Files
rikkei_simple_care/management/src/components/ExamGridProctor.tsx
PhuocNTB 31943bf489
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m23s
rollback fix
2026-07-13 15:14:50 +07:00

399 lines
13 KiB
TypeScript

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 subscribedRef = useRef<Set<number>>(new Set());
const gridContainerRef = useRef<HTMLDivElement>(null);
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 syncSubscriptions = (ws: WebSocket) => {
if (ws.readyState !== WebSocket.OPEN) return;
const currentOnlineIds = onlineIdsRef.current;
const target = new Set(currentOnlineIds);
const prev = subscribedRef.current;
prev.forEach((id) => {
if (!target.has(id)) {
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } }));
prev.delete(id);
}
});
currentOnlineIds.forEach((id) => {
if (!prev.has(id)) {
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id } }));
prev.add(id);
}
});
};
useEffect(() => {
if (students.length === 0) return;
const wsUrl = getWsUrl('/ws?role=teacher');
let closed = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let attempt = 0;
const clearPing = () => {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
};
const connect = () => {
if (closed) return;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
subscribedRef.current = new Set();
ws.onopen = () => {
attempt = 0;
syncSubscriptions(ws);
clearPing();
pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ event: 'client:ping', data: {} }));
}
}, 15000);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.event === 'client:pong') return;
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);
}
};
ws.onclose = () => {
clearPing();
subscribedRef.current = new Set();
if (!closed) {
const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000);
retryTimer = setTimeout(connect, delay);
}
};
};
connect();
return () => {
closed = true;
clearPing();
if (retryTimer) clearTimeout(retryTimer);
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
subscribedRef.current.forEach((id) => {
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } }));
});
}
ws?.close();
wsRef.current = null;
subscribedRef.current = new Set();
};
}, [studentIdsString]);
useEffect(() => {
const ws = wsRef.current;
if (ws) syncSubscriptions(ws);
}, [onlineKey]);
// 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>
);
};