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

This commit is contained in:
2026-07-20 06:06:54 +07:00
parent aed692cffe
commit 6ee9773f7f
15 changed files with 561 additions and 447 deletions

View File

@@ -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' },

View File

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

View File

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

View 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}
/>
);
};

View File

@@ -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> 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>
)}

View File

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

View File

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