fix mang
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m23s

This commit is contained in:
2026-07-13 15:23:48 +07:00
parent 31943bf489
commit 83821b6b82
2 changed files with 300 additions and 27 deletions

View File

@@ -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 }}
>
&larr; 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 &rarr;
</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,
}}
>
&times;
</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>
);
};

View File

@@ -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;