tam 2
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m25s

This commit is contained in:
2026-07-13 09:04:09 +07:00
parent 719704f19e
commit 8803cd622a
22 changed files with 1322 additions and 121 deletions

View File

@@ -1,18 +1,59 @@
import { useCallback, useEffect, useRef } from 'react';
import { type ChatMessage, getWsUrl } from '../api';
import { useAuth } from '../auth/AuthContext';
import { playChatSound } from '../utils/notifySound';
import { playChatSound, playAlertSound } from '../utils/notifySound';
type ChatIncomingHandler = (msg: ChatMessage) => void;
export type PresenceUpdate = { studentId: number; online: boolean; classId?: number };
export type StudentViolationEvent = {
studentId: number;
studentName?: string;
studentCode?: string;
kind: string;
reason: string;
monitorMode?: string;
classId?: number;
};
const handlers = new Set<ChatIncomingHandler>();
const chatHandlers = new Set<ChatIncomingHandler>();
const presenceHandlers = new Set<(p: PresenceUpdate) => void>();
const violationHandlers = new Set<(v: StudentViolationEvent) => void>();
export function onStaffChatMessage(handler: ChatIncomingHandler): () => void {
handlers.add(handler);
return () => { handlers.delete(handler); };
chatHandlers.add(handler);
return () => { chatHandlers.delete(handler); };
}
/** WebSocket luôn bật khi đã login — nhận tin sinh viên realtime */
export function onStudentPresence(handler: (p: PresenceUpdate) => void): () => void {
presenceHandlers.add(handler);
return () => { presenceHandlers.delete(handler); };
}
export function onStudentViolation(handler: (v: StudentViolationEvent) => void): () => void {
violationHandlers.add(handler);
return () => { violationHandlers.delete(handler); };
}
function scheduleBackoff(attempt: number): number {
const base = Math.min(1000 * 2 ** Math.min(attempt, 4), 10000);
return base + Math.floor(Math.random() * 250);
}
export function kindLabel(kind: string): string {
switch (kind) {
case 'app_closed': return 'Tự đóng app';
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';
case 'session_change': return 'Khóa / đổi phiên';
case 'virtual_desktop': return 'Desktop ảo';
case 'wifi': return 'WiFi trái phép';
case 'guard': return 'Vi phạm môi trường';
default: return kind || 'Vi phạm';
}
}
/** WebSocket luôn bật khi đã login — chat + presence + violation + keepalive. */
export function StaffChatSocket() {
const { staff, token } = useAuth();
const staffIdRef = useRef(0);
@@ -21,8 +62,8 @@ export function StaffChatSocket() {
staffIdRef.current = staff?.id ?? 0;
}, [staff?.id]);
const dispatch = useCallback((msg: ChatMessage) => {
handlers.forEach((h) => h(msg));
const dispatchChat = useCallback((msg: ChatMessage) => {
chatHandlers.forEach((h) => h(msg));
}, []);
useEffect(() => {
@@ -31,26 +72,74 @@ export function StaffChatSocket() {
const wsUrl = getWsUrl(`/ws?role=teacher&staffId=${staff.id}`);
let ws: WebSocket | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let closed = false;
let attempt = 0;
const clearPing = () => {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
};
const connect = () => {
if (closed) return;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
attempt = 0;
clearPing();
pingTimer = setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ event: 'client:ping', data: {} }));
}
}, 15000);
};
ws.onmessage = (ev) => {
try {
const payload = JSON.parse(ev.data);
if (payload.event === 'client:pong') return;
if (payload.event === 'presence:update') {
const studentId = Number(payload.data?.studentId ?? 0);
if (!studentId) return;
presenceHandlers.forEach((h) => h({
studentId,
online: !!payload.data?.online,
classId: Number(payload.data?.classId ?? 0) || undefined,
}));
return;
}
if (payload.event === 'teacher:student-violation') {
const studentId = Number(payload.data?.studentId ?? 0);
if (!studentId) return;
playAlertSound();
violationHandlers.forEach((h) => h({
studentId,
studentName: payload.data?.studentName || '',
studentCode: payload.data?.studentCode || '',
kind: String(payload.data?.kind || ''),
reason: String(payload.data?.reason || ''),
monitorMode: payload.data?.monitorMode || '',
classId: Number(payload.data?.classId ?? 0) || undefined,
}));
return;
}
if (payload.event !== 'chat:message') return;
const msg = payload.data as ChatMessage;
const targetStaff = Number(msg.targetStaffId ?? msg.staffId ?? 0);
if (targetStaff > 0 && targetStaff !== staffIdRef.current) return;
dispatch(msg);
dispatchChat(msg);
} catch {
/* ignore */
}
};
ws.onclose = () => {
clearPing();
if (!closed) {
retryTimer = setTimeout(connect, 3000);
retryTimer = setTimeout(connect, scheduleBackoff(attempt++));
}
};
};
@@ -59,12 +148,13 @@ export function StaffChatSocket() {
return () => {
closed = true;
clearPing();
if (retryTimer) clearTimeout(retryTimer);
ws?.close();
};
}, [token, staff?.id, dispatch]);
}, [token, staff?.id, dispatchChat]);
return null;
}
export { playChatSound };
export { playChatSound, playAlertSound };