This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user