This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ký 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) => ({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 } }));
|
||||
|
||||
127
management/src/components/ViolationsPanel.tsx
Normal file
127
management/src/components/ViolationsPanel.tsx
Normal 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 có 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>Mã 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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user