diff --git a/client/app.go b/client/app.go index 6f05cb7..87a99ed 100644 --- a/client/app.go +++ b/client/app.go @@ -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+")") - a.tearDownBeforeQuit() - a.clearRunLock() - return false + log.Printf("[CLOSE] MessageDialog error: %v — hủy thoát để an toàn", err) + return true } - 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 + if !isConfirmQuitSelection(selection) { + return true // Không / Cancel → ở lại } - return true // Prevent close! + // 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 +} + +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 + } + // 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) diff --git a/management/src/api.ts b/management/src/api.ts index ca84e00..83695ed 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -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' }, diff --git a/management/src/components/ExamGridProctor.tsx b/management/src/components/ExamGridProctor.tsx index bc3000e..5c29166 100644 --- a/management/src/components/ExamGridProctor.tsx +++ b/management/src/components/ExamGridProctor.tsx @@ -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 = ({ onlineIds, onSelectStudent, }) => { - const [screenFrames, setScreenFrames] = useState>({}); - const [webcamFrames, setWebcamFrames] = useState>({}); const [gridCols, setGridCols] = useState(3); const [showWebcamOverlay, setShowWebcamOverlay] = useState(true); const [searchQuery, setSearchQuery] = useState(''); @@ -24,8 +23,6 @@ export const ExamGridProctor: React.FC = ({ const [pageSize, setPageSize] = useState(12); const [currentPage, setCurrentPage] = useState(1); - const wsRef = useRef(null); - const subscribedRef = useRef>(new Set()); const gridContainerRef = useRef(null); useEffect(() => { @@ -38,11 +35,6 @@ export const ExamGridProctor: React.FC = ({ 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 = ({ } }, [totalPages, currentPage]); - const visibleOnlineIds = useMemo(() => { - return pagedStudents - .map((s) => s.studentRkId) - .filter((id) => onlineIds.includes(id)); - }, [pagedStudents, onlineIds]); - - const visibleOnlineIdsRef = useRef(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 | null = null; - let pingTimer: ReturnType | 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 = ({ > {pagedStudents.map((s) => { const isOnline = onlineIds.includes(s.studentRkId); - const screenFrame = screenFrames[s.studentRkId]; - const webcamFrame = webcamFrames[s.studentRkId]; return (
= ({ }} style={{ cursor: 'zoom-in' }} > - {screenFrame ? ( - {`Màn - ) : ( -
-
- Đang kết nối màn hình... -
- )} + - {showWebcamOverlay && webcamFrame && ( + {showWebcamOverlay && (
- {`Webcam
)} @@ -607,47 +434,40 @@ export const ExamGridProctor: React.FC = ({ overflow: 'hidden', }} > - {screenFrames[zoomedStudent.studentRkId] ? ( -
- Zoomed Screen + + {showWebcamOverlay && ( +
- {showWebcamOverlay && webcamFrames[zoomedStudent.studentRkId] && ( -
- Zoomed Webcam -
- )} -
- ) : ( -
-
- Đang tải màn hình... -
- )} + > + +
+ )} +
diff --git a/management/src/components/ProctorStreamPanels.tsx b/management/src/components/ProctorStreamPanels.tsx index 1885bbe..38d69d3 100644 --- a/management/src/components/ProctorStreamPanels.tsx +++ b/management/src/components/ProctorStreamPanels.tsx @@ -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 = ({ studentId, layout = 'focus', }) => { - const [screenFrame, setScreenFrame] = useState(null); - const [webcamFrame, setWebcamFrame] = useState(null); - const [streaming, setStreaming] = useState(false); - const [errorMessage, setErrorMessage] = useState(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(null); - const intentionalClose = useRef(false); - const hasOpened = useRef(false); - const hasFrames = useRef(false); const screenPanelRef = useRef(null); const screenZoom = ZOOM_STEPS[screenZoomIdx]; const webcamZoom = ZOOM_STEPS[webcamZoomIdx]; - useEffect(() => { - const wsUrl = getWsUrl('/ws?role=teacher'); - let retryTimer: ReturnType | null = null; - let pingTimer: ReturnType | 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 = ({ return (
- - {streaming || screenFrame || webcamFrame ? '● Đang phát' : '○ Đang kết nối...'} + + ● Đang phát (HTTP)
- {errorMessage && !screenFrame && !webcamFrame && ( -
{errorMessage}
- )} -
= ({
- {screenFrame ? ( - Màn hình sinh viên - ) : ( -

Đang chờ màn hình...

- )} +
@@ -236,17 +119,12 @@ export const ProctorStreamPanels: React.FC = ({
- {webcamFrame ? ( - Webcam sinh viên - ) : ( -

Đang chờ webcam...

- )} +
diff --git a/management/src/components/StudentStreamImage.tsx b/management/src/components/StudentStreamImage.tsx new file mode 100644 index 0000000..f189479 --- /dev/null +++ b/management/src/components/StudentStreamImage.tsx @@ -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 = ({ + 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 ( +
+

Đang chờ {kind === 'screen' ? 'màn hình' : 'webcam'}...

+
+ ); + } + + return ( + {`${kind + ); +}; diff --git a/management/src/components/ViolationsPanel.tsx b/management/src/components/ViolationsPanel.tsx index b36dbbf..afc6ed6 100644 --- a/management/src/components/ViolationsPanel.tsx +++ b/management/src/components/ViolationsPanel.tsx @@ -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 (
- {props.mode === 'exam' && ( -
- ⚠️ 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 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.'} +
+