This commit is contained in:
@@ -387,6 +387,7 @@ export interface StudentViolationItem {
|
||||
studentCode: string;
|
||||
fullName: string;
|
||||
classRkId: number;
|
||||
examRoomId?: number;
|
||||
kind: string;
|
||||
reason: string;
|
||||
monitorMode: string;
|
||||
@@ -396,7 +397,7 @@ export interface StudentViolationItem {
|
||||
|
||||
export const VIOLATION_KIND_OPTIONS = [
|
||||
{ value: '', label: 'Tất cả loại' },
|
||||
{ value: 'app_closed', label: 'Tự đóng app' },
|
||||
{ value: 'app_closed', label: 'Tắt ứng dụng' },
|
||||
{ value: 'unclean_shutdown', label: 'Tắt đột ngột' },
|
||||
{ value: 'multi_monitor', label: 'Nhiều màn hình' },
|
||||
{ value: 'user_switch', label: 'Đổi user' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { getWsUrl, type ExamRoomStudent } from '../api';
|
||||
import { type ExamRoomStudent } from '../api';
|
||||
import { openStaffChat } from '../chatEvents';
|
||||
import { StudentStreamImage } from './StudentStreamImage';
|
||||
|
||||
interface ExamGridProctorProps {
|
||||
students: ExamRoomStudent[];
|
||||
@@ -13,8 +14,6 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
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>('');
|
||||
@@ -24,8 +23,6 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
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(() => {
|
||||
@@ -38,11 +35,6 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const studentIdsString = useMemo(
|
||||
() => students.map((s) => s.studentRkId).join(','),
|
||||
[students]
|
||||
);
|
||||
|
||||
const filteredStudents = useMemo(() => {
|
||||
return students.filter((s) => {
|
||||
const matchesSearch =
|
||||
@@ -71,160 +63,6 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
}
|
||||
}, [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 currentVisibleIds = visibleOnlineIdsRef.current;
|
||||
const target = new Set(currentVisibleIds);
|
||||
const prev = subscribedRef.current;
|
||||
|
||||
prev.forEach((id) => {
|
||||
if (!target.has(id)) {
|
||||
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } }));
|
||||
prev.delete(id);
|
||||
}
|
||||
});
|
||||
currentVisibleIds.forEach((id) => {
|
||||
if (!prev.has(id)) {
|
||||
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id, mode: 'grid' } }));
|
||||
prev.add(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const ws = wsRef.current;
|
||||
if (ws) syncSubscriptions(ws);
|
||||
}, [onlineKey]);
|
||||
|
||||
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]);
|
||||
|
||||
// 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 handleOpenChat = (s: ExamRoomStudent, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
openStaffChat({
|
||||
@@ -368,8 +206,6 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
>
|
||||
{pagedStudents.map((s) => {
|
||||
const isOnline = onlineIds.includes(s.studentRkId);
|
||||
const screenFrame = screenFrames[s.studentRkId];
|
||||
const webcamFrame = webcamFrames[s.studentRkId];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -397,27 +233,18 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
}}
|
||||
style={{ cursor: 'zoom-in' }}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<StudentStreamImage
|
||||
studentId={s.studentRkId}
|
||||
kind="screen"
|
||||
className="proctor-screen-image"
|
||||
/>
|
||||
|
||||
{showWebcamOverlay && webcamFrame && (
|
||||
{showWebcamOverlay && (
|
||||
<div className="proctor-webcam-overlay">
|
||||
<img
|
||||
src={webcamFrame}
|
||||
alt={`Webcam ${s.fullName}`}
|
||||
<StudentStreamImage
|
||||
studentId={s.studentRkId}
|
||||
kind="webcam"
|
||||
className="proctor-webcam-image"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -607,47 +434,40 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
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"
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<StudentStreamImage
|
||||
studentId={zoomedStudent.studentRkId}
|
||||
kind="screen"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '65vh',
|
||||
objectFit: 'contain',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
{showWebcamOverlay && (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '65vh',
|
||||
objectFit: 'contain',
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
>
|
||||
<StudentStreamImage
|
||||
studentId={zoomedStudent.studentRkId}
|
||||
kind="webcam"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { getWsUrl } from '../api';
|
||||
import { StudentStreamImage } from './StudentStreamImage';
|
||||
|
||||
interface ProctorStreamPanelsProps {
|
||||
studentId: number;
|
||||
@@ -12,124 +12,16 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
studentId,
|
||||
layout = 'focus',
|
||||
}) => {
|
||||
const [screenFrame, setScreenFrame] = useState<string | null>(null);
|
||||
const [webcamFrame, setWebcamFrame] = useState<string | null>(null);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showWebcam, setShowWebcam] = useState(true);
|
||||
const [screenZoomIdx, setScreenZoomIdx] = useState(2); // 1x
|
||||
const [webcamZoomIdx, setWebcamZoomIdx] = useState(2);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const intentionalClose = useRef(false);
|
||||
const hasOpened = useRef(false);
|
||||
const hasFrames = useRef(false);
|
||||
const screenPanelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const screenZoom = ZOOM_STEPS[screenZoomIdx];
|
||||
const webcamZoom = ZOOM_STEPS[webcamZoomIdx];
|
||||
|
||||
useEffect(() => {
|
||||
const wsUrl = getWsUrl('/ws?role=teacher');
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let attempt = 0;
|
||||
|
||||
intentionalClose.current = false;
|
||||
hasOpened.current = false;
|
||||
hasFrames.current = false;
|
||||
setStreaming(false);
|
||||
setErrorMessage(null);
|
||||
setScreenFrame(null);
|
||||
setWebcamFrame(null);
|
||||
|
||||
const markStreaming = () => {
|
||||
if (!hasFrames.current) {
|
||||
hasFrames.current = true;
|
||||
setStreaming(true);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearPing = () => {
|
||||
if (pingTimer) {
|
||||
clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (intentionalClose.current) return;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
attempt = 0;
|
||||
hasOpened.current = true;
|
||||
setStreaming(true);
|
||||
setErrorMessage(null);
|
||||
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId, mode: 'focus' } }));
|
||||
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' && msg.data.studentId == studentId) {
|
||||
setScreenFrame(msg.data.imageBuffer);
|
||||
markStreaming();
|
||||
} else if (msg.event === 'teacher:webcam-stream-frame' && msg.data.studentId == studentId) {
|
||||
setWebcamFrame(msg.data.imageBuffer);
|
||||
markStreaming();
|
||||
} else if (msg.event === 'teacher:stream-stopped' && msg.data.studentId == studentId) {
|
||||
setScreenFrame(null);
|
||||
setWebcamFrame(null);
|
||||
setStreaming(false);
|
||||
setErrorMessage('Sinh viên đã dừng stream');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing WS frame:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearPing();
|
||||
if (intentionalClose.current) return;
|
||||
setStreaming(false);
|
||||
if (!hasOpened.current && !hasFrames.current) {
|
||||
setErrorMessage('Không thể kết nối máy chủ giám sát');
|
||||
} else {
|
||||
setErrorMessage('Mất kết nối giám sát — đang thử lại...');
|
||||
}
|
||||
const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000);
|
||||
retryTimer = setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
intentionalClose.current = true;
|
||||
clearPing();
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } }));
|
||||
}
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [studentId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onFsChange = () => {
|
||||
setIsFullscreen(document.fullscreenElement === screenPanelRef.current);
|
||||
@@ -184,8 +76,8 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
return (
|
||||
<div className="proctor-stream-wrap">
|
||||
<div className="proctor-stream-toolbar">
|
||||
<span className={`status-pill ${streaming || screenFrame || webcamFrame ? 'connected' : 'connecting'}`}>
|
||||
{streaming || screenFrame || webcamFrame ? '● Đang phát' : '○ Đang kết nối...'}
|
||||
<span className="status-pill connected">
|
||||
● Đang phát (HTTP)
|
||||
</span>
|
||||
<div className="proctor-stream-actions">
|
||||
<button
|
||||
@@ -198,10 +90,6 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage && !screenFrame && !webcamFrame && (
|
||||
<div className="alert-error proctor-stream-error">{errorMessage}</div>
|
||||
)}
|
||||
|
||||
<div className={`proctor-grid proctor-grid-${layout} ${!showWebcam ? 'proctor-grid--no-webcam' : ''}`}>
|
||||
<div
|
||||
className={`proctor-panel screen-panel ${isFullscreen ? 'screen-panel--fullscreen' : ''}`}
|
||||
@@ -213,17 +101,12 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
</div>
|
||||
<div className="panel-body screen-body" onDoubleClick={toggleFullscreen} title="Double-click để phóng to">
|
||||
<div className="proctor-zoom-viewport">
|
||||
{screenFrame ? (
|
||||
<img
|
||||
src={screenFrame}
|
||||
alt="Màn hình sinh viên"
|
||||
className="live-frame screen-img"
|
||||
style={{ transform: `scale(${screenZoom})` }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="no-stream-placeholder"><p>Đang chờ màn hình...</p></div>
|
||||
)}
|
||||
<StudentStreamImage
|
||||
studentId={studentId}
|
||||
kind="screen"
|
||||
className="live-frame screen-img"
|
||||
style={{ transform: `scale(${screenZoom})` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,17 +119,12 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
</div>
|
||||
<div className="panel-body webcam-body">
|
||||
<div className="proctor-zoom-viewport proctor-zoom-viewport--webcam">
|
||||
{webcamFrame ? (
|
||||
<img
|
||||
src={webcamFrame}
|
||||
alt="Webcam sinh viên"
|
||||
className="live-frame webcam-img"
|
||||
style={{ transform: `scale(${webcamZoom})` }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="no-stream-placeholder"><p>Đang chờ webcam...</p></div>
|
||||
)}
|
||||
<StudentStreamImage
|
||||
studentId={studentId}
|
||||
kind="webcam"
|
||||
className="live-frame webcam-img"
|
||||
style={{ transform: `scale(${webcamZoom})` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
55
management/src/components/StudentStreamImage.tsx
Normal file
55
management/src/components/StudentStreamImage.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { API_BASE } from '../api';
|
||||
|
||||
interface StudentStreamImageProps {
|
||||
studentId: number;
|
||||
kind: 'screen' | 'webcam';
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const StudentStreamImage: React.FC<StudentStreamImageProps> = ({
|
||||
studentId,
|
||||
kind,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('sc_staff_token') || '';
|
||||
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
setUrl(`${API_BASE}/students/${studentId}/stream/${kind}${tokenParam}`);
|
||||
setError(false);
|
||||
}, [studentId, kind]);
|
||||
|
||||
const handleError = () => {
|
||||
setError(true);
|
||||
setTimeout(() => {
|
||||
const token = localStorage.getItem('sc_staff_token') || '';
|
||||
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
setUrl(`${API_BASE}/students/${studentId}/stream/${kind}${tokenParam}&t=${Date.now()}`);
|
||||
setError(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
if (error || !url) {
|
||||
return (
|
||||
<div className="no-stream-placeholder">
|
||||
<p>Đang chờ {kind === 'screen' ? 'màn hình' : 'webcam'}...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={`${kind === 'screen' ? 'Màn hình' : 'Webcam'} sinh viên`}
|
||||
className={className}
|
||||
style={style}
|
||||
onError={handleError}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
VIOLATION_KIND_OPTIONS,
|
||||
type StudentViolationItem,
|
||||
} from '../api';
|
||||
import { kindLabel } from '../hooks/useStaffChatSocket';
|
||||
import { kindLabel, onStudentViolation } from '../hooks/useStaffChatSocket';
|
||||
|
||||
type Props =
|
||||
| { mode: 'class'; classId: number }
|
||||
@@ -24,6 +24,14 @@ function formatTime(iso?: string): string {
|
||||
return d.toLocaleString('vi-VN');
|
||||
}
|
||||
|
||||
function modeLabel(mode?: string): string {
|
||||
switch (mode) {
|
||||
case 'exam': return 'Phòng thi';
|
||||
case 'learning': return 'Lớp học';
|
||||
default: return mode || '—';
|
||||
}
|
||||
}
|
||||
|
||||
export const ViolationsPanel = (props: Props) => {
|
||||
const [date, setDate] = useState(todayLocal);
|
||||
const [kind, setKind] = useState('');
|
||||
@@ -52,25 +60,36 @@ export const ViolationsPanel = (props: Props) => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// Live: khi SV tắt app / vi phạm → tự làm mới nếu thuộc lớp/phòng đang xem
|
||||
useEffect(() => {
|
||||
return onStudentViolation((v) => {
|
||||
const matches =
|
||||
props.mode === 'class'
|
||||
? Number(v.classId) === props.classId
|
||||
: Number(v.examRoomId) === props.examId ||
|
||||
(!v.examRoomId && v.monitorMode === 'exam');
|
||||
if (!matches) return;
|
||||
if (date !== todayLocal()) return;
|
||||
void load();
|
||||
});
|
||||
}, [props, date, load]);
|
||||
|
||||
return (
|
||||
<div className="session-logs-panel" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', height: '100%' }}>
|
||||
{props.mode === 'exam' && (
|
||||
<div style={{
|
||||
padding: '0.65rem 0.85rem',
|
||||
background: '#fef3c7',
|
||||
border: '1px solid #fcd34d',
|
||||
borderRadius: '6px',
|
||||
color: '#92400e',
|
||||
fontSize: '0.82rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
margin: '0',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
⚠️ <strong>Lưu ý:</strong> Chức năng theo dõi vi phạm đang được theo dõi đánh giá tính chuẩn xác, hiện tại kết quả chạy thử chỉ mang tính chất tham khảo thu thập dữ liệu theo các loại máy.
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
padding: '0.65rem 0.85rem',
|
||||
background: props.mode === 'exam' ? '#fef3c7' : '#eff6ff',
|
||||
border: props.mode === 'exam' ? '1px solid #fcd34d' : '1px solid #bfdbfe',
|
||||
borderRadius: '6px',
|
||||
color: props.mode === 'exam' ? '#92400e' : '#1e40af',
|
||||
fontSize: '0.82rem',
|
||||
lineHeight: '1.4',
|
||||
}}>
|
||||
{props.mode === 'exam'
|
||||
? '⚠ Vi phạm trong phòng thi (tắt app, WiFi, môi trường…) được ghi nhận theo thời gian thực.'
|
||||
: 'ℹ Vi phạm trong giờ học (tắt app, WiFi, môi trường…) hiển thị tại đây theo từng lớp.'}
|
||||
</div>
|
||||
|
||||
<div className="attendance-toolbar" style={{ marginBottom: 0, flexWrap: 'wrap' }}>
|
||||
<label className="attendance-field">
|
||||
<span>Ngày</span>
|
||||
@@ -113,28 +132,36 @@ export const ViolationsPanel = (props: Props) => {
|
||||
<th>Mã SV</th>
|
||||
<th>Loại</th>
|
||||
<th>Chi tiết</th>
|
||||
<th>Chế độ</th>
|
||||
<th>Ngữ cảnh</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
{formatTime(r.createdAt || r.clientAt)}
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{r.fullName || '—'}</td>
|
||||
<td><code>{r.studentCode || r.studentRkId}</code></td>
|
||||
<td>
|
||||
<span className="badge badge-warning" style={{ fontSize: '0.72rem' }}>
|
||||
{kindLabel(r.kind)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ maxWidth: 360, fontSize: '0.85rem' }} title={r.reason}>
|
||||
{r.reason}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{r.monitorMode || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.map((r) => {
|
||||
const isClose = r.kind === 'app_closed' || r.kind === 'unclean_shutdown';
|
||||
return (
|
||||
<tr key={r.id} style={isClose ? { background: 'rgba(239, 68, 68, 0.06)' } : undefined}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
{formatTime(r.createdAt || r.clientAt)}
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{r.fullName || '—'}</td>
|
||||
<td><code>{r.studentCode || r.studentRkId}</code></td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${isClose ? 'badge-danger' : 'badge-warning'}`}
|
||||
style={{ fontSize: '0.72rem' }}
|
||||
>
|
||||
{kindLabel(r.kind)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ maxWidth: 360, fontSize: '0.85rem' }} title={r.reason}>
|
||||
{r.reason}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
{modeLabel(r.monitorMode)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type StudentViolationEvent = {
|
||||
reason: string;
|
||||
monitorMode?: string;
|
||||
classId?: number;
|
||||
examRoomId?: number;
|
||||
};
|
||||
|
||||
const chatHandlers = new Set<ChatIncomingHandler>();
|
||||
@@ -41,7 +42,7 @@ function scheduleBackoff(attempt: number): number {
|
||||
|
||||
export function kindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'app_closed': return 'Tự đóng app';
|
||||
case 'app_closed': return 'Tắt ứng dụng';
|
||||
case 'unclean_shutdown': return 'Tắt đột ngột';
|
||||
case 'multi_monitor': return 'Nhiều màn hình';
|
||||
case 'user_switch': return 'Đổi user';
|
||||
@@ -123,6 +124,7 @@ export function StaffChatSocket() {
|
||||
reason: String(payload.data?.reason || ''),
|
||||
monitorMode: payload.data?.monitorMode || '',
|
||||
classId: Number(payload.data?.classId ?? 0) || undefined,
|
||||
examRoomId: Number(payload.data?.examRoomId ?? 0) || undefined,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -843,6 +843,12 @@ input:checked + .slider:before {
|
||||
border: 1px solid rgba(180, 83, 9, 0.25);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background-color: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid rgba(185, 28, 28, 0.25);
|
||||
}
|
||||
|
||||
.courses-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user