fix xem man hinh:
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m25s
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m25s
This commit is contained in:
@@ -20,6 +20,17 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
|||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
const [onlyOnline, setOnlyOnline] = useState<boolean>(false);
|
const [onlyOnline, setOnlyOnline] = useState<boolean>(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||||
|
const [zoomedStudent, setZoomedStudent] = useState<ExamRoomStudent | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setZoomedStudent(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const subscribedRef = useRef<Set<number>>(new Set());
|
const subscribedRef = useRef<Set<number>>(new Set());
|
||||||
@@ -31,9 +42,13 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
|||||||
);
|
);
|
||||||
const onlineKey = useMemo(() => onlineIds.join(','), [onlineIds]);
|
const onlineKey = useMemo(() => onlineIds.join(','), [onlineIds]);
|
||||||
|
|
||||||
|
const onlineIdsRef = useRef<number[]>(onlineIds);
|
||||||
|
onlineIdsRef.current = onlineIds;
|
||||||
|
|
||||||
const syncSubscriptions = (ws: WebSocket) => {
|
const syncSubscriptions = (ws: WebSocket) => {
|
||||||
if (ws.readyState !== WebSocket.OPEN) return;
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
const target = new Set(onlineIds);
|
const currentOnlineIds = onlineIdsRef.current;
|
||||||
|
const target = new Set(currentOnlineIds);
|
||||||
const prev = subscribedRef.current;
|
const prev = subscribedRef.current;
|
||||||
|
|
||||||
prev.forEach((id) => {
|
prev.forEach((id) => {
|
||||||
@@ -42,7 +57,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
|||||||
prev.delete(id);
|
prev.delete(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
onlineIds.forEach((id) => {
|
currentOnlineIds.forEach((id) => {
|
||||||
if (!prev.has(id)) {
|
if (!prev.has(id)) {
|
||||||
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id, mode: 'grid' } }));
|
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id, mode: 'grid' } }));
|
||||||
prev.add(id);
|
prev.add(id);
|
||||||
@@ -321,7 +336,14 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
|||||||
|
|
||||||
<div className="grid-proctor-card-body">
|
<div className="grid-proctor-card-body">
|
||||||
{isOnline ? (
|
{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 ? (
|
{screenFrame ? (
|
||||||
<img
|
<img
|
||||||
src={screenFrame}
|
src={screenFrame}
|
||||||
@@ -389,6 +411,173 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{zoomedStudent && (
|
||||||
|
<div
|
||||||
|
className="zoomed-proctor-overlay"
|
||||||
|
onClick={() => setZoomedStudent(null)}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
backgroundColor: 'rgba(10, 15, 30, 0.85)',
|
||||||
|
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: '1200px',
|
||||||
|
backgroundColor: '#161d2a',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '1rem 1.5rem',
|
||||||
|
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
backgroundColor: '#1c2436',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: onlineIds.includes(zoomedStudent.studentRkId) ? '#10b981' : '#ef4444',
|
||||||
|
boxShadow: onlineIds.includes(zoomedStudent.studentRkId) ? '0 0 8px #10b981' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<h3 style={{ margin: 0, color: '#fff', fontSize: '1.1rem', fontWeight: 600 }}>
|
||||||
|
{zoomedStudent.fullName}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: '#8f9cae',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{zoomedStudent.studentCode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setZoomedStudent(null)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#8f9cae',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
lineHeight: 1,
|
||||||
|
transition: 'color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => (e.currentTarget.style.color = '#fff')}
|
||||||
|
onMouseOut={(e) => (e.currentTarget.style.color = '#8f9cae')}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#0d131f',
|
||||||
|
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: '70vh',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showWebcamOverlay && webcamFrames[zoomedStudent.studentRkId] && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '20px',
|
||||||
|
right: '20px',
|
||||||
|
width: '240px',
|
||||||
|
aspectRatio: '4/3',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid rgba(255, 255, 255, 0.2)',
|
||||||
|
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: '#8f9cae', 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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user