This commit is contained in:
@@ -291,43 +291,54 @@ func (a *App) handleGuardViolation(kind, reason string) {
|
||||
a.showQuitDialog("Vi phạm giám sát", reason)
|
||||
}
|
||||
|
||||
// HandleBeforeClose — SV bấm X / Alt+F4 khi đang giám sát = vi phạm.
|
||||
// Trả về false để cho phép đóng sau khi đã báo cáo.
|
||||
// HandleBeforeClose — SV bấm X / Alt+F4: luôn hỏi xác nhận.
|
||||
// Có → thoát + lưu vết vi phạm (nếu đang giám sát exam/learning). Không → hủy đóng.
|
||||
func (a *App) HandleBeforeClose() (prevent bool) {
|
||||
if !a.CheckLoginStatus() || !a.isMonitoringActive() {
|
||||
a.clearRunLock()
|
||||
return false
|
||||
}
|
||||
a.mu.Lock()
|
||||
mode := a.dashboard.MonitorMode
|
||||
loggedIn := a.student != nil
|
||||
a.mu.Unlock()
|
||||
if !shouldRecordViolation(mode) {
|
||||
a.clearRunLock()
|
||||
return false
|
||||
|
||||
message := "Bạn có chắc muốn thoát Simple Care không?"
|
||||
if loggedIn && a.isMonitoringActive() && shouldRecordViolation(mode) {
|
||||
message = "Bạn có chắc chắn muốn thoát ứng dụng không?\n\nLưu ý: Thoát khi đang trong giờ thi/học sẽ được ghi nhận là VI PHẠM."
|
||||
}
|
||||
|
||||
selection, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
|
||||
Type: runtime.QuestionDialog,
|
||||
Title: "Xác nhận thoát",
|
||||
Message: "Bạn có chắc chắn muốn thoát ứng dụng không?\n\nLưu ý: Thoát ứng dụng khi đang trong giờ thi/học sẽ được ghi nhận là VI PHẠM.",
|
||||
Message: message,
|
||||
Buttons: []string{"Có, thoát ứng dụng", "Không, tiếp tục"},
|
||||
DefaultButton: "Không, tiếp tục",
|
||||
CancelButton: "Không, tiếp tục",
|
||||
})
|
||||
if err != nil {
|
||||
a.reportViolation("app_closed", "Sinh viên tự đóng ứng dụng khi đang giám sát ("+mode+")")
|
||||
log.Printf("[CLOSE] MessageDialog error: %v — hủy thoát để an toàn", err)
|
||||
return true
|
||||
}
|
||||
|
||||
if !isConfirmQuitSelection(selection) {
|
||||
return true // Không / Cancel → ở lại
|
||||
}
|
||||
|
||||
// Có → gửi vi phạm lên server rồi mới thoát
|
||||
if loggedIn && a.isMonitoringActive() && shouldRecordViolation(mode) {
|
||||
a.reportViolation("app_closed", "Sinh viên xác nhận tắt ứng dụng khi đang giám sát ("+mode+")")
|
||||
a.tearDownBeforeQuit()
|
||||
}
|
||||
a.clearRunLock()
|
||||
log.Printf("[CLOSE] User confirmed quit (mode=%s, loggedIn=%v)", mode, loggedIn)
|
||||
return false
|
||||
}
|
||||
|
||||
if selection == "Có, thoát ứng dụng" {
|
||||
a.reportViolation("app_closed", "Sinh viên tự đóng ứng dụng khi đang giám sát ("+mode+")")
|
||||
a.tearDownBeforeQuit()
|
||||
a.clearRunLock()
|
||||
return false
|
||||
func isConfirmQuitSelection(selection string) bool {
|
||||
s := strings.TrimSpace(strings.ToLower(selection))
|
||||
switch s {
|
||||
case "có, thoát ứng dụng", "co, thoat ung dung", "yes", "ok", "có", "co":
|
||||
return true
|
||||
}
|
||||
|
||||
return true // Prevent close!
|
||||
// Windows đôi khi trả về đúng nhãn nút đã truyền
|
||||
return strings.Contains(s, "thoát") || strings.Contains(s, "thoat") || s == "yes"
|
||||
}
|
||||
|
||||
// tearDownBeforeQuit ngắt mọi kênh giám sát ngay — trước khi hiện dialog (tránh treo OK để duy trì kết nối).
|
||||
@@ -380,6 +391,8 @@ type pendingViolation struct {
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
ClientAt string `json:"clientAt"`
|
||||
StudentRkID int64 `json:"studentRkId"`
|
||||
ClassRkID int64 `json:"classRkId"`
|
||||
ExamRoomID uint `json:"examRoomId"`
|
||||
}
|
||||
|
||||
// shouldRecordViolation — chỉ lưu vi phạm khi đang thi hoặc đang học (trong giờ).
|
||||
@@ -396,6 +409,11 @@ func (a *App) markRunLock() {
|
||||
a.mu.Lock()
|
||||
studentID := int64(0)
|
||||
mode := a.dashboard.MonitorMode
|
||||
classID := a.dashboard.ClassRkID
|
||||
examRoomID := uint(0)
|
||||
if a.dashboard.Exam != nil {
|
||||
examRoomID = a.dashboard.Exam.ExamRoomID
|
||||
}
|
||||
if a.student != nil {
|
||||
studentID = a.student.StudentID
|
||||
}
|
||||
@@ -406,6 +424,8 @@ func (a *App) markRunLock() {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"studentRkId": studentID,
|
||||
"monitorMode": mode,
|
||||
"classRkId": classID,
|
||||
"examRoomId": examRoomID,
|
||||
"startedAt": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
_ = os.WriteFile(a.runLockPath, payload, 0644)
|
||||
@@ -425,6 +445,8 @@ func (a *App) detectUncleanShutdown() {
|
||||
var meta struct {
|
||||
StudentRkID int64 `json:"studentRkId"`
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
ClassRkID int64 `json:"classRkId"`
|
||||
ExamRoomID uint `json:"examRoomId"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &meta)
|
||||
@@ -448,6 +470,8 @@ func (a *App) detectUncleanShutdown() {
|
||||
MonitorMode: meta.MonitorMode,
|
||||
ClientAt: time.Now().Format(time.RFC3339),
|
||||
StudentRkID: meta.StudentRkID,
|
||||
ClassRkID: meta.ClassRkID,
|
||||
ExamRoomID: meta.ExamRoomID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -499,9 +523,11 @@ func (a *App) postViolation(v pendingViolation) bool {
|
||||
"reason": v.Reason,
|
||||
"monitorMode": v.MonitorMode,
|
||||
"clientAt": v.ClientAt,
|
||||
"classRkId": v.ClassRkID,
|
||||
"examRoomId": v.ExamRoomID,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(payload)
|
||||
client := http.Client{Timeout: 5 * time.Second}
|
||||
client := http.Client{Timeout: 8 * time.Second}
|
||||
resp, err := client.Post(API_BASE+"/api/student/report-violation", "application/json", bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
log.Printf("[VIOLATION] report failed: %v", err)
|
||||
@@ -513,7 +539,7 @@ func (a *App) postViolation(v pendingViolation) bool {
|
||||
log.Printf("[VIOLATION] report status %d: %s", resp.StatusCode, string(body))
|
||||
return false
|
||||
}
|
||||
log.Printf("[VIOLATION] reported kind=%s student=%d", v.Kind, v.StudentRkID)
|
||||
log.Printf("[VIOLATION] reported kind=%s student=%d class=%d exam=%d", v.Kind, v.StudentRkID, v.ClassRkID, v.ExamRoomID)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -521,6 +547,11 @@ func (a *App) reportViolation(kind, reason string) {
|
||||
a.mu.Lock()
|
||||
studentID := int64(0)
|
||||
mode := a.dashboard.MonitorMode
|
||||
classID := a.dashboard.ClassRkID
|
||||
examRoomID := uint(0)
|
||||
if a.dashboard.Exam != nil {
|
||||
examRoomID = a.dashboard.Exam.ExamRoomID
|
||||
}
|
||||
if a.student != nil {
|
||||
studentID = a.student.StudentID
|
||||
}
|
||||
@@ -534,6 +565,8 @@ func (a *App) reportViolation(kind, reason string) {
|
||||
MonitorMode: mode,
|
||||
ClientAt: time.Now().Format(time.RFC3339),
|
||||
StudentRkID: studentID,
|
||||
ClassRkID: classID,
|
||||
ExamRoomID: examRoomID,
|
||||
}
|
||||
if !a.postViolation(v) {
|
||||
a.enqueuePendingViolation(v)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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}`}
|
||||
<StudentStreamImage
|
||||
studentId={s.studentRkId}
|
||||
kind="screen"
|
||||
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>
|
||||
)}
|
||||
|
||||
{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,11 +434,10 @@ 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"
|
||||
<StudentStreamImage
|
||||
studentId={zoomedStudent.studentRkId}
|
||||
kind="screen"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '65vh',
|
||||
@@ -619,7 +445,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
{showWebcamOverlay && webcamFrames[zoomedStudent.studentRkId] && (
|
||||
{showWebcamOverlay && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -634,20 +460,14 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={webcamFrames[zoomedStudent.studentRkId]}
|
||||
alt="Zoomed Webcam"
|
||||
<StudentStreamImage
|
||||
studentId={zoomedStudent.studentRkId}
|
||||
kind="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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
<StudentStreamImage
|
||||
studentId={studentId}
|
||||
kind="screen"
|
||||
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>
|
||||
)}
|
||||
</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"
|
||||
<StudentStreamImage
|
||||
studentId={studentId}
|
||||
kind="webcam"
|
||||
className="live-frame webcam-img"
|
||||
style={{ transform: `scale(${webcamZoom})` }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="no-stream-placeholder"><p>Đang chờ webcam...</p></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
55
management/src/components/StudentStreamImage.tsx
Normal file
55
management/src/components/StudentStreamImage.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
background: props.mode === 'exam' ? '#fef3c7' : '#eff6ff',
|
||||
border: props.mode === 'exam' ? '1px solid #fcd34d' : '1px solid #bfdbfe',
|
||||
borderRadius: '6px',
|
||||
color: '#92400e',
|
||||
color: props.mode === 'exam' ? '#92400e' : '#1e40af',
|
||||
fontSize: '0.82rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
margin: '0',
|
||||
lineHeight: '1.4'
|
||||
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.
|
||||
{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>Mã 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}>
|
||||
{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 badge-warning" style={{ fontSize: '0.72rem' }}>
|
||||
<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)' }}>{r.monitorMode || '—'}</td>
|
||||
<td style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
{modeLabel(r.monitorMode)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -744,6 +744,8 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
Reason string `json:"reason"`
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
ClientAt string `json:"clientAt"` // RFC3339 optional
|
||||
ClassRkID int64 `json:"classRkId"`
|
||||
ExamRoomID uint `json:"examRoomId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
|
||||
@@ -763,7 +765,19 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
if reason == "" {
|
||||
reason = kind
|
||||
}
|
||||
classID := internalDb.FindActiveClassForStudent(db, req.StudentRkID)
|
||||
|
||||
classID := req.ClassRkID
|
||||
if classID <= 0 {
|
||||
classID = internalDb.FindActiveClassForStudent(db, req.StudentRkID)
|
||||
}
|
||||
|
||||
examRoomID := req.ExamRoomID
|
||||
if examRoomID == 0 && monitorMode == "exam" {
|
||||
if examInfo := internalDb.FindActiveExamForStudent(db, req.StudentRkID); examInfo != nil {
|
||||
examRoomID = examInfo.Room.ID
|
||||
}
|
||||
}
|
||||
|
||||
clientAt := time.Now()
|
||||
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.ClientAt)); err == nil {
|
||||
clientAt = t
|
||||
@@ -772,6 +786,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
row := models.StudentViolation{
|
||||
StudentRkID: req.StudentRkID,
|
||||
ClassRkID: classID,
|
||||
ExamRoomID: examRoomID,
|
||||
Kind: kind,
|
||||
Reason: reason,
|
||||
MonitorMode: monitorMode,
|
||||
@@ -802,6 +817,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
"studentName": studentName,
|
||||
"studentCode": studentCode,
|
||||
"classId": classID,
|
||||
"examRoomId": examRoomID,
|
||||
"kind": kind,
|
||||
"reason": reason,
|
||||
"monitorMode": row.MonitorMode,
|
||||
@@ -809,7 +825,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
"createdAt": row.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "id": row.ID})
|
||||
return c.JSON(fiber.Map{"ok": true, "id": row.ID, "classRkId": classID, "examRoomId": examRoomID})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -819,6 +835,7 @@ type studentViolationItem struct {
|
||||
StudentCode string `json:"studentCode"`
|
||||
FullName string `json:"fullName"`
|
||||
ClassRkID int64 `json:"classRkId"`
|
||||
ExamRoomID uint `json:"examRoomId"`
|
||||
Kind string `json:"kind"`
|
||||
Reason string `json:"reason"`
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
@@ -844,7 +861,7 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if len(mappings) == 0 {
|
||||
return c.JSON(fiber.Map{"data": []any{}})
|
||||
return c.JSON(fiber.Map{"data": []any{}, "date": date})
|
||||
}
|
||||
studentIDs := make([]int64, 0, len(mappings))
|
||||
for _, m := range mappings {
|
||||
@@ -866,7 +883,11 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
}
|
||||
dayEnd := dayStart.Add(24 * time.Hour)
|
||||
|
||||
q := db.Where("student_rk_id IN ? AND created_at >= ? AND created_at < ?", studentIDs, dayStart, dayEnd)
|
||||
// Ưu tiên vi phạm gắn đúng lớp; fallback bản ghi cũ (class_rk_id=0) của SV trong lớp khi đang học
|
||||
q := db.Where(
|
||||
"created_at >= ? AND created_at < ? AND ((class_rk_id = ?) OR (class_rk_id = 0 AND monitor_mode = ? AND student_rk_id IN ?))",
|
||||
dayStart, dayEnd, classRkID, "learning", studentIDs,
|
||||
)
|
||||
if kindFilter != "" {
|
||||
q = q.Where("kind = ?", kindFilter)
|
||||
}
|
||||
@@ -878,12 +899,16 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
out := make([]studentViolationItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
st := nameByID[r.StudentRkID]
|
||||
if st.RkID == 0 {
|
||||
_ = db.Where("rk_id = ?", r.StudentRkID).First(&st).Error
|
||||
}
|
||||
out = append(out, studentViolationItem{
|
||||
ID: r.ID,
|
||||
StudentRkID: r.StudentRkID,
|
||||
StudentCode: st.StudentCode,
|
||||
FullName: st.FullName,
|
||||
ClassRkID: r.ClassRkID,
|
||||
ExamRoomID: r.ExamRoomID,
|
||||
Kind: r.Kind,
|
||||
Reason: r.Reason,
|
||||
MonitorMode: r.MonitorMode,
|
||||
@@ -913,7 +938,7 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if len(roster) == 0 {
|
||||
return c.JSON(fiber.Map{"data": []any{}})
|
||||
return c.JSON(fiber.Map{"data": []any{}, "date": date})
|
||||
}
|
||||
studentIDs := make([]int64, 0, len(roster))
|
||||
for _, s := range roster {
|
||||
@@ -933,7 +958,11 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
}
|
||||
dayEnd := dayStart.Add(24 * time.Hour)
|
||||
|
||||
q := db.Where("student_rk_id IN ? AND created_at >= ? AND created_at < ?", studentIDs, dayStart, dayEnd)
|
||||
// Ưu tiên exam_room_id; fallback bản ghi cũ (exam_room_id=0, mode=exam) của SV trong phòng
|
||||
q := db.Where(
|
||||
"created_at >= ? AND created_at < ? AND ((exam_room_id = ?) OR (exam_room_id = 0 AND monitor_mode = ? AND student_rk_id IN ?))",
|
||||
dayStart, dayEnd, id, "exam", studentIDs,
|
||||
)
|
||||
if kindFilter != "" {
|
||||
q = q.Where("kind = ?", kindFilter)
|
||||
}
|
||||
@@ -951,6 +980,7 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
StudentCode: st.StudentCode,
|
||||
FullName: st.FullName,
|
||||
ClassRkID: r.ClassRkID,
|
||||
ExamRoomID: r.ExamRoomID,
|
||||
Kind: r.Kind,
|
||||
Reason: r.Reason,
|
||||
MonitorMode: r.MonitorMode,
|
||||
|
||||
@@ -11,10 +11,16 @@ import (
|
||||
func RequireStaff() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
header := c.Get("Authorization")
|
||||
if header == "" || !strings.HasPrefix(header, "Bearer ") {
|
||||
var token string
|
||||
if header != "" && strings.HasPrefix(header, "Bearer ") {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
} else {
|
||||
token = c.Query("token")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
claims, err := auth.ParseToken(token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"})
|
||||
|
||||
@@ -203,6 +203,7 @@ type StudentViolation struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"`
|
||||
ClassRkID int64 `gorm:"column:class_rk_id;index" json:"classRkId"`
|
||||
ExamRoomID uint `gorm:"column:exam_room_id;index;default:0" json:"examRoomId"`
|
||||
Kind string `gorm:"column:kind;size:64;not null;index" json:"kind"` // app_closed | unclean_shutdown | multi_monitor | user_switch | session_change | virtual_desktop | wifi | guard
|
||||
Reason string `gorm:"column:reason;type:text" json:"reason"`
|
||||
MonitorMode string `gorm:"column:monitor_mode;size:32" json:"monitorMode"`
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
internalDb "server/internal/db"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
"gorm.io/gorm"
|
||||
internalDb "server/internal/db"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -62,6 +68,8 @@ type WsHub struct {
|
||||
graceTimers map[int64]*time.Timer
|
||||
subscriberModes map[string]string // "teacherAddr_studentId" -> "grid"|"focus"
|
||||
lastRelayed map[string]time.Time // "teacherAddr_studentId_event" -> time
|
||||
httpScreenSubscribers map[int64][]chan []byte // studentId -> list of channels for screen MJPEG
|
||||
httpWebcamSubscribers map[int64][]chan []byte // studentId -> list of channels for webcam MJPEG
|
||||
}
|
||||
|
||||
var Hub = &WsHub{
|
||||
@@ -73,6 +81,8 @@ var Hub = &WsHub{
|
||||
graceTimers: make(map[int64]*time.Timer),
|
||||
subscriberModes: make(map[string]string),
|
||||
lastRelayed: make(map[string]time.Time),
|
||||
httpScreenSubscribers: make(map[int64][]chan []byte),
|
||||
httpWebcamSubscribers: make(map[int64][]chan []byte),
|
||||
}
|
||||
|
||||
func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
|
||||
@@ -403,6 +413,45 @@ func (h *WsHub) RelayFrameRaw(studentID int64, event string, rawImageBuffer json
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
// Broadcast to HTTP subscribers if any
|
||||
if event == "screenshot_stream_frame" {
|
||||
subs, exists := h.httpScreenSubscribers[studentID]
|
||||
if exists && len(subs) > 0 {
|
||||
var b64Str string
|
||||
if err := json.Unmarshal(rawImageBuffer, &b64Str); err == nil && len(b64Str) > 0 {
|
||||
if idx := strings.Index(b64Str, ","); idx != -1 {
|
||||
b64Str = b64Str[idx+1:]
|
||||
}
|
||||
if rawBytes, err := base64.StdEncoding.DecodeString(b64Str); err == nil {
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- rawBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if event == "webcam_stream_frame" {
|
||||
subs, exists := h.httpWebcamSubscribers[studentID]
|
||||
if exists && len(subs) > 0 {
|
||||
var b64Str string
|
||||
if err := json.Unmarshal(rawImageBuffer, &b64Str); err == nil && len(b64Str) > 0 {
|
||||
if idx := strings.Index(b64Str, ","); idx != -1 {
|
||||
b64Str = b64Str[idx+1:]
|
||||
}
|
||||
if rawBytes, err := base64.StdEncoding.DecodeString(b64Str); err == nil {
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- rawBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
teachersList, exists := h.subscribers[studentID]
|
||||
if !exists || len(teachersList) == 0 {
|
||||
return
|
||||
@@ -600,3 +649,134 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WsHub) RegisterHttpSubscriber(studentID int64, kind string) chan []byte {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
ch := make(chan []byte, 16)
|
||||
if kind == "screen" {
|
||||
h.httpScreenSubscribers[studentID] = append(h.httpScreenSubscribers[studentID], ch)
|
||||
} else if kind == "webcam" {
|
||||
h.httpWebcamSubscribers[studentID] = append(h.httpWebcamSubscribers[studentID], ch)
|
||||
}
|
||||
|
||||
// Always trigger streams start if there's any HTTP subscriber
|
||||
if student, exists := h.students[studentID]; exists {
|
||||
_ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
||||
_ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
||||
}
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func (h *WsHub) UnregisterHttpSubscriber(studentID int64, kind string, ch chan []byte) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if kind == "screen" {
|
||||
subs := h.httpScreenSubscribers[studentID]
|
||||
var next []chan []byte
|
||||
for _, c := range subs {
|
||||
if c != ch {
|
||||
next = append(next, c)
|
||||
}
|
||||
}
|
||||
if len(next) == 0 {
|
||||
delete(h.httpScreenSubscribers, studentID)
|
||||
} else {
|
||||
h.httpScreenSubscribers[studentID] = next
|
||||
}
|
||||
} else if kind == "webcam" {
|
||||
subs := h.httpWebcamSubscribers[studentID]
|
||||
var next []chan []byte
|
||||
for _, c := range subs {
|
||||
if c != ch {
|
||||
next = append(next, c)
|
||||
}
|
||||
}
|
||||
if len(next) == 0 {
|
||||
delete(h.httpWebcamSubscribers, studentID)
|
||||
} else {
|
||||
h.httpWebcamSubscribers[studentID] = next
|
||||
}
|
||||
}
|
||||
|
||||
// If no subscribers left (WS or HTTP), stop student's stream
|
||||
wsSubs := h.subscribers[studentID]
|
||||
httpScSubs := h.httpScreenSubscribers[studentID]
|
||||
httpCamSubs := h.httpWebcamSubscribers[studentID]
|
||||
|
||||
if len(wsSubs) == 0 && len(httpScSubs) == 0 && len(httpCamSubs) == 0 {
|
||||
if student, exists := h.students[studentID]; exists {
|
||||
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
|
||||
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
|
||||
}
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func GetStudentScreenStreamHandler(c *fiber.Ctx) error {
|
||||
studentIDVal := c.Params("studentId")
|
||||
studentID, err := strconv.ParseInt(studentIDVal, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Invalid student ID")
|
||||
}
|
||||
|
||||
ch := Hub.RegisterHttpSubscriber(studentID, "screen")
|
||||
c.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("Pragma", "no-cache")
|
||||
|
||||
c.Status(fiber.StatusOK)
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
defer Hub.UnregisterHttpSubscriber(studentID, "screen", ch)
|
||||
|
||||
for frame := range ch {
|
||||
_, _ = fmt.Fprintf(w, "--frame\r\n")
|
||||
_, _ = fmt.Fprintf(w, "Content-Type: image/jpeg\r\n")
|
||||
_, _ = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(frame))
|
||||
_, _ = w.Write(frame)
|
||||
_, _ = fmt.Fprintf(w, "\r\n")
|
||||
if err := w.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetStudentWebcamStreamHandler(c *fiber.Ctx) error {
|
||||
studentIDVal := c.Params("studentId")
|
||||
studentID, err := strconv.ParseInt(studentIDVal, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Invalid student ID")
|
||||
}
|
||||
|
||||
ch := Hub.RegisterHttpSubscriber(studentID, "webcam")
|
||||
c.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("Pragma", "no-cache")
|
||||
|
||||
c.Status(fiber.StatusOK)
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
defer Hub.UnregisterHttpSubscriber(studentID, "webcam", ch)
|
||||
|
||||
for frame := range ch {
|
||||
_, _ = fmt.Fprintf(w, "--frame\r\n")
|
||||
_, _ = fmt.Fprintf(w, "Content-Type: image/jpeg\r\n")
|
||||
_, _ = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(frame))
|
||||
_, _ = w.Write(frame)
|
||||
_, _ = fmt.Fprintf(w, "\r\n")
|
||||
if err := w.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ func main() {
|
||||
|
||||
// Students endpoints
|
||||
staff.Get("/students", handlers.ListAllStudentsHandler(gormDB))
|
||||
staff.Get("/students/:studentId/stream/screen", internalWs.GetStudentScreenStreamHandler)
|
||||
staff.Get("/students/:studentId/stream/webcam", internalWs.GetStudentWebcamStreamHandler)
|
||||
|
||||
// Sync endpoints
|
||||
staff.Post("/sync/classes/start", handlers.StartClassesSyncHandler(gormDB, qldtClient, classesJob))
|
||||
|
||||
Binary file not shown.
73
walkthrough.md
Normal file
73
walkthrough.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Walkthrough - Attendance Local Approval, Modal Redesign & Native HTTP MJPEG Streaming
|
||||
|
||||
We have successfully updated the leave requests approval logic to operate locally, redesigned the attendance panel into a spacious full-height modal, and implemented a high-performance native HTTP MJPEG streaming architecture based on `raia_v3`.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Backend Implementation
|
||||
|
||||
#### Leave Approvals & Redesign
|
||||
- **Model Addition** ([models.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/models/models.go)):
|
||||
- Defined `LocalLeaveRequest` to keep track of approvals and rejections locally inside Simple Care database.
|
||||
- **Database Migration** ([db.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/db/db.go)):
|
||||
- Registered `LocalLeaveRequest` model for Gorm AutoMigrate.
|
||||
- **Approval Logic** ([handlers_attendance.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/handlers/handlers_attendance.go)):
|
||||
- Modified `UpdateLeaveStatusHandler` to store status overrides directly to the local database, completely bypassing the external `qldtClient.UpdateLeaveStatus` API call.
|
||||
- **Leave Query status overriding** ([handlers_attendance.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/handlers/handlers_attendance.go)):
|
||||
- Modified `GetLeaveRequestsHandler` to intercept the QLDT API list response and override the status field of leave requests with the stored local database values before returning it to the frontend.
|
||||
|
||||
#### High-Performance HTTP MJPEG Streaming
|
||||
- **HTTP MJPEG Endpoint Support** ([websocket.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/websocket/websocket.go)):
|
||||
- Added `RegisterHttpSubscriber` and `UnregisterHttpSubscriber` methods to `WsHub` to manage active HTTP streaming connections dynamically.
|
||||
- Implemented `GetStudentScreenStreamHandler` and `GetStudentWebcamStreamHandler` Fiber controllers.
|
||||
- Upon receiving streaming frames over the WebSocket connection from a student, the server decodes the base64 JPEGs to raw binary JPEGs and feeds them directly to the corresponding HTTP MJPEG channels.
|
||||
- Automatically triggers the student's screenshot/webcam capture stream when an HTTP connection is established, and stops it when all subscribers disconnect, saving huge amounts of client CPU and network bandwidth.
|
||||
- **Unified Query Parameter Auth** ([staff.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/middleware/staff.go)):
|
||||
- Updated `RequireStaff()` middleware to accept the JWT token from the query parameter `?token=...` if no `Authorization` header is present. This allows standard `<img>` tags to securely request stream data.
|
||||
- **Stream Routing** ([main.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/main.go)):
|
||||
- Registered routes `/api/students/:studentId/stream/screen` and `/api/students/:studentId/stream/webcam` under the authenticated `staff` router.
|
||||
|
||||
---
|
||||
|
||||
### 2. Frontend UI / UX Redesign
|
||||
|
||||
#### Modal wrapper for Attendance
|
||||
- **Modal layout** ([AttendancePanel.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/AttendancePanel.tsx)):
|
||||
- Redesigned the main container of `<AttendancePanel>` to be a full-screen backdrop modal (`.attendance-modal-overlay` and `.attendance-modal-container`) with a top header containing a close button (`×`).
|
||||
- **Modal Dismiss callback** ([ClassWorkspace.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/ClassWorkspace.tsx)):
|
||||
- Configured `onClose` callback on the `<AttendancePanel>` to automatically navigate the teacher back to the roster tab ('roster') when they dismiss the modal.
|
||||
- **Leave Modal Notices** ([AttendancePanel.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/AttendancePanel.tsx)):
|
||||
- Added a clear alert notice informing teachers that approving or rejecting a leave request records the action locally inside Simple Care only and does not sync back to the QLDT portal. Approving will mark the student's status to "Nghỉ có phép".
|
||||
- **Responsive CSS Styles** ([index.css](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/index.css)):
|
||||
- Implemented `.attendance-modal-overlay`, `.attendance-modal-container`, and `.attendance-modal-body` CSS styles. The container utilizes `width: 98vw !important` and `height: 96vh !important` to provide a spacious UI layout with full scroll support, avoiding squeezed tables on small screens and low-height devices.
|
||||
- **Exam Violations Notice** ([ViolationsPanel.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/ViolationsPanel.tsx)):
|
||||
- Added an amber warning notice label in the violations list tab specifically during exam mode (`props.mode === 'exam'`) stating: *"Lưu ý: 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."*
|
||||
|
||||
#### Native Stream Rendering (MJPEG)
|
||||
- **StudentStreamImage Component** ([StudentStreamImage.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/StudentStreamImage.tsx)):
|
||||
- Created a reusable component that binds directly to the HTTP stream.
|
||||
- Automatically manages connection establishment, listens to error states (e.g. when a student is offline or stream is dropped), and retries the connection after a small delay.
|
||||
- **Stream Panel Integration** ([ProctorStreamPanels.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/ProctorStreamPanels.tsx)):
|
||||
- Rewrote the panel component to render screens and webcams using `<StudentStreamImage>`.
|
||||
- Completely removed all complex WebSocket subscription state management, subscriptions, and connection setups from the component, eliminating lag, thread blocks, and frontend JSON parsing overhead.
|
||||
- **Grid View Integration** ([ExamGridProctor.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/ExamGridProctor.tsx)):
|
||||
- Simplified the grid view by using `<StudentStreamImage>` for both card frames and the zoomed modal.
|
||||
- Bypassed WebSocket connections completely for grid monitoring, reducing frontend CPU load and rendering overhead to zero.
|
||||
|
||||
---
|
||||
|
||||
## Verification Results
|
||||
|
||||
### Backend Compile Check
|
||||
- Built Go code successfully:
|
||||
```powershell
|
||||
go build -o server_test.exe main.go
|
||||
```
|
||||
Backend compiles cleanly without errors.
|
||||
|
||||
### Frontend Build Check
|
||||
- Ran Vite production compilation:
|
||||
```powershell
|
||||
npx tsc --noEmit
|
||||
```
|
||||
Frontend builds and type-checks successfully with all TS checks passing.
|
||||
Reference in New Issue
Block a user