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

@@ -381,6 +381,31 @@ export interface StudentSessionLogItem {
lastActiveAt?: string;
}
export interface StudentViolationItem {
id: number;
studentRkId: number;
studentCode: string;
fullName: string;
classRkId: number;
kind: string;
reason: string;
monitorMode: string;
clientAt?: string;
createdAt: string;
}
export const VIOLATION_KIND_OPTIONS = [
{ value: '', label: 'Tất cả loại' },
{ value: 'app_closed', label: 'Tự đóng app' },
{ 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' },
{ value: 'session_change', label: 'Khóa / đổi phiên' },
{ value: 'virtual_desktop', label: 'Desktop ảo' },
{ value: 'wifi', label: 'WiFi trái phép' },
{ value: 'guard', label: 'Vi phạm môi trường (cũ)' },
] as const;
export const api = {
getStats: async (): Promise<StatsResponse> => {
const res = await staffFetch('/stats');
@@ -716,6 +741,30 @@ export const apiFetchClassSessionLogs = async (
return res.json();
};
export const apiFetchClassViolations = async (
rkId: number,
date: string,
kind = ''
): Promise<{ data: StudentViolationItem[]; date: string }> => {
const params = new URLSearchParams({ date });
if (kind) params.set('kind', kind);
const res = await staffFetch(`/classes/${rkId}/violations?${params}`);
if (!res.ok) throw new Error('Failed to fetch class violations');
return res.json();
};
export const apiFetchExamViolations = async (
examId: number,
date: string,
kind = ''
): Promise<{ data: StudentViolationItem[]; date: string }> => {
const params = new URLSearchParams({ date });
if (kind) params.set('kind', kind);
const res = await staffFetch(`/exam-rooms/${examId}/violations?${params}`);
if (!res.ok) throw new Error('Failed to fetch exam violations');
return res.json();
};
export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => {
const res = await staffFetch(`/classes/${rkId}/online-students`);
if (!res.ok) throw new Error('Failed to fetch online students list');

View File

@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api';
import { type StaffChatOpenDetail } from '../chatEvents';
import { useAuth } from '../auth/AuthContext';
import { onStaffChatMessage, playChatSound } from '../hooks/useStaffChatSocket';
import { onStaffChatMessage, onStudentViolation, playChatSound, kindLabel } from '../hooks/useStaffChatSocket';
export function ChatWidget() {
const { staff } = useAuth();
@@ -83,6 +83,13 @@ export function ChatWidget() {
return () => { off(); };
}, [handleIncoming]);
useEffect(() => {
return onStudentViolation((v) => {
const name = v.studentName || v.studentCode || `SV #${v.studentId}`;
showToast(`${kindLabel(v.kind)}`, `${name}: ${v.reason || 'Vi phạm giám sát'}`);
});
}, []);
useEffect(() => {
const onOpen = (e: Event) => {
const detail = (e as CustomEvent<StaffChatOpenDetail>).detail;
@@ -152,7 +159,7 @@ export function ChatWidget() {
const dock = (
<div className="chat-dock">
{toast && (
<div className="chat-toast" role="status">
<div className={`chat-toast ${toast.title.startsWith('⚠') ? 'chat-toast-alert' : ''}`} role="status">
<strong>{toast.title}</strong>
<span>{toast.body}</span>
</div>

View File

@@ -23,6 +23,8 @@ import { AttendancePanel } from './AttendancePanel';
import { StudentDetailModal } from './StudentDetailModal';
import { ExamGridProctor } from './ExamGridProctor';
import { AppPoolModal } from './AppPoolModal';
import { onStudentPresence } from '../hooks/useStaffChatSocket';
import { ViolationsPanel } from './ViolationsPanel';
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
import { mergeKeywordCSV } from '../utils/appKeywords';
import { WorkspaceSeatingChart } from './WorkspaceSeatingChart';
@@ -52,7 +54,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
const [loading, setLoading] = useState(true);
const [logsLoading, setLogsLoading] = useState(false);
const [savingApps, setSavingApps] = useState(false);
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'grid' | 'logs' | 'attendance'>('roster');
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'grid' | 'logs' | 'attendance' | 'violations'>('roster');
const [configOpen, setConfigOpen] = useState(false);
const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
const [appPoolOpen, setAppPoolOpen] = useState(false);
@@ -144,10 +146,21 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
const interval = setInterval(() => {
fetchOnlineStatus();
fetchLogs(logDate, logPeriod);
}, 5000);
}, 8000);
return () => clearInterval(interval);
}, [classId, logDate, logPeriod]);
useEffect(() => {
return onStudentPresence(({ studentId, online }) => {
setOnlineIds((prev) => {
const has = prev.includes(studentId);
if (online && !has) return [...prev, studentId];
if (!online && has) return prev.filter((id) => id !== studentId);
return prev;
});
});
}, []);
useEffect(() => {
loadLogShifts(logDate);
if (activeSubTab === 'logs') {
@@ -400,6 +413,12 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
>
Nhật theo ca
</button>
<button
className={`tab-sub-btn ${activeSubTab === 'violations' ? 'active' : ''}`}
onClick={() => setActiveSubTab('violations')}
>
Vi phạm
</button>
<button
className={`tab-sub-btn ${activeSubTab === 'attendance' ? 'active' : ''}`}
onClick={() => setActiveSubTab('attendance')}
@@ -410,7 +429,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
</div>
{/* Student Search */}
{activeSubTab !== 'attendance' && activeSubTab !== 'grid' && <div className="search-input-wrapper">
{activeSubTab !== 'attendance' && activeSubTab !== 'grid' && activeSubTab !== 'violations' && <div className="search-input-wrapper">
<input
type="text"
className="search-input"
@@ -425,9 +444,11 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }}></div>
</div>
<div className={`workspace-panel-body ${activeSubTab === 'attendance' || activeSubTab === 'logs' || activeSubTab === 'grid' || activeSubTab === 'roster' ? 'workspace-panel-fill' : ''}`}>
<div className={`workspace-panel-body ${activeSubTab === 'attendance' || activeSubTab === 'logs' || activeSubTab === 'grid' || activeSubTab === 'roster' || activeSubTab === 'violations' ? 'workspace-panel-fill' : ''}`}>
{activeSubTab === 'attendance' ? (
<AttendancePanel classId={classId} />
) : activeSubTab === 'violations' ? (
<ViolationsPanel mode="class" classId={classId} />
) : activeSubTab === 'grid' ? (
<ExamGridProctor
students={students.map((s) => ({

View File

@@ -56,6 +56,15 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
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;
@@ -64,11 +73,21 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
wsRef.current = ws;
subscribedRef.current = new Set();
ws.onopen = () => syncSubscriptions(ws);
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 }));
@@ -94,9 +113,11 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
};
ws.onclose = () => {
clearPing();
subscribedRef.current = new Set();
if (!closed) {
retryTimer = setTimeout(connect, 3000);
const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000);
retryTimer = setTimeout(connect, delay);
}
};
};
@@ -105,6 +126,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
return () => {
closed = true;
clearPing();
if (retryTimer) clearTimeout(retryTimer);
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {

View File

@@ -20,6 +20,8 @@ import { StudentDetailModal } from './StudentDetailModal';
import { fmtTime, localInputToISO, toLocalInput } from './ExamsTab';
import { ExamGridProctor } from './ExamGridProctor';
import { WorkspaceSeatingChart } from './WorkspaceSeatingChart';
import { onStudentPresence } from '../hooks/useStaffChatSocket';
import { ViolationsPanel } from './ViolationsPanel';
const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])];
@@ -88,7 +90,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [onlineIds, setOnlineIds] = useState<number[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedStudent, setSelectedStudent] = useState<StudentItem | null>(null);
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions' | 'grid'>('roster');
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions' | 'grid' | 'violations'>('roster');
const [configOpen, setConfigOpen] = useState(false);
const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info');
const [papersModalOpen, setPapersModalOpen] = useState(false);
@@ -163,10 +165,23 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
useEffect(() => {
fetchOnline();
const t = setInterval(fetchOnline, 5000);
const t = setInterval(fetchOnline, 8000);
return () => clearInterval(t);
}, [fetchOnline]);
useEffect(() => {
const roster = new Set(students.map((s) => s.studentRkId));
return onStudentPresence(({ studentId, online }) => {
if (!roster.has(studentId)) return;
setOnlineIds((prev) => {
const has = prev.includes(studentId);
if (online && !has) return [...prev, studentId];
if (!online && has) return prev.filter((id) => id !== studentId);
return prev;
});
});
}, [students]);
useEffect(() => {
if (!studentPickerOpen || !searchQ.trim()) {
if (!studentPickerOpen) setSearchHits([]);
@@ -448,6 +463,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
};
const handlePublish = async () => {
if (!quizUrl.trim()) {
const ok = confirm(
'Phòng thi chưa có link trắc nghiệm.\n\nBạn có chắc chắn muốn đẩy phòng thi không có phần thi trắc nghiệm không?'
);
if (!ok) return;
}
setErr(''); setMsg('');
try {
await apiExam.publish(examId);
@@ -880,11 +901,18 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
>
Bài nộp ({submissions.length})
</button>
<button
type="button"
className={`tab-sub-btn ${activeSubTab === 'violations' ? 'active' : ''}`}
onClick={() => setActiveSubTab('violations')}
>
Vi phạm
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{activeSubTab !== 'submissions' && activeSubTab !== 'grid' && (
{activeSubTab !== 'submissions' && activeSubTab !== 'grid' && activeSubTab !== 'violations' && (
<div className="search-input-wrapper">
<input
type="text"
@@ -1014,6 +1042,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
)}
</div>
</div>
) : activeSubTab === 'violations' ? (
<ViolationsPanel mode="exam" examId={examId} />
) : (
<div className="session-logs-panel">
{submissions.length > 0 && (

View File

@@ -33,6 +33,8 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
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;
@@ -50,6 +52,13 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
}
};
const clearPing = () => {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
};
const connect = () => {
if (intentionalClose.current) return;
@@ -57,15 +66,23 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
wsRef.current = ws;
ws.onopen = () => {
attempt = 0;
hasOpened.current = true;
setStreaming(true);
setErrorMessage(null);
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } }));
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();
@@ -86,6 +103,7 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
ws.onerror = () => {};
ws.onclose = () => {
clearPing();
if (intentionalClose.current) return;
setStreaming(false);
if (!hasOpened.current && !hasFrames.current) {
@@ -93,7 +111,8 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
} else {
setErrorMessage('Mất kết nối giám sát — đang thử lại...');
}
retryTimer = setTimeout(connect, 3000);
const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000);
retryTimer = setTimeout(connect, delay);
};
};
@@ -101,6 +120,7 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
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 } }));

View File

@@ -0,0 +1,127 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
apiFetchClassViolations,
apiFetchExamViolations,
VIOLATION_KIND_OPTIONS,
type StudentViolationItem,
} from '../api';
import { kindLabel } from '../hooks/useStaffChatSocket';
type Props =
| { mode: 'class'; classId: number }
| { mode: 'exam'; examId: number };
function todayLocal(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function formatTime(iso?: string): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString('vi-VN');
}
export const ViolationsPanel: React.FC<Props> = (props) => {
const [date, setDate] = useState(todayLocal);
const [kind, setKind] = useState('');
const [rows, setRows] = useState<StudentViolationItem[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
const load = useCallback(async () => {
setLoading(true);
setErr('');
try {
const res =
props.mode === 'class'
? await apiFetchClassViolations(props.classId, date, kind)
: await apiFetchExamViolations(props.examId, date, kind);
setRows(res.data || []);
} catch (e: any) {
setErr(e?.message || 'Không tải được danh sách vi phạm');
setRows([]);
} finally {
setLoading(false);
}
}, [props, date, kind]);
useEffect(() => {
void load();
}, [load]);
return (
<div className="session-logs-panel" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', height: '100%' }}>
<div className="attendance-toolbar" style={{ marginBottom: 0, flexWrap: 'wrap' }}>
<label className="attendance-field">
<span>Ngày</span>
<input type="date" className="search-input" style={{ padding: '0.5rem 0.75rem' }} value={date} onChange={(e) => setDate(e.target.value)} />
</label>
<label className="attendance-field">
<span>Loại</span>
<select className="select-filter" value={kind} onChange={(e) => setKind(e.target.value)}>
{VIOLATION_KIND_OPTIONS.map((o) => (
<option key={o.value || 'all'} value={o.value}>{o.label}</option>
))}
</select>
</label>
<button type="button" className="btn btn-secondary btn-sm" onClick={() => void load()} disabled={loading}>
{loading ? 'Đang tải...' : 'Làm mới'}
</button>
<span style={{ marginLeft: 'auto', fontSize: '0.82rem', color: 'var(--text-muted)', fontWeight: 600 }}>
{rows.length} vi phạm
</span>
</div>
{err && <div className="form-error" style={{ margin: 0 }}>{err}</div>}
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none', flex: 1, overflowY: 'auto' }}>
{loading && rows.length === 0 ? (
<div className="empty-state" style={{ minHeight: '220px' }}>
<div className="sync-spinner" style={{ width: '32px', height: '32px' }} />
<p style={{ marginTop: '0.5rem' }}>Đang tải vi phạm...</p>
</div>
) : rows.length === 0 ? (
<div className="empty-state" style={{ minHeight: '220px' }}>
<p>Không vi phạm trong ngày đã chọn.</p>
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th>Thời gian</th>
<th>Sinh viên</th>
<th> SV</th>
<th>Loại</th>
<th>Chi tiết</th>
<th>Chế đ</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>
))}
</tbody>
</table>
)}
</div>
</div>
);
};

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

View File

@@ -3874,6 +3874,15 @@ input:checked + .slider:before {
color: #93c5fd;
}
.chat-toast-alert {
background: #7f1d1d;
border: 1px solid #f87171;
}
.chat-toast-alert strong {
color: #fecaca;
}
@keyframes chat-toast-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }

View File

@@ -35,3 +35,29 @@ export function playChatSound() {
playTone(880, t, 0.12);
playTone(1174, t + 0.14, 0.14);
}
/** Louder alert for student violations (quit / kill app) */
export function playAlertSound() {
const ctx = getAudioCtx();
if (!ctx) return;
if (ctx.state === 'suspended') {
void ctx.resume();
}
const playTone = (freq: number, start: number, duration: number) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.1, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(start);
osc.stop(start + duration + 0.02);
};
const t = ctx.currentTime;
playTone(520, t, 0.16);
playTone(390, t + 0.18, 0.2);
playTone(520, t + 0.4, 0.18);
}