fix
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m45s

This commit is contained in:
2026-07-20 06:06:54 +07:00
parent aed692cffe
commit 6ee9773f7f
15 changed files with 561 additions and 447 deletions

View File

@@ -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)

View File

@@ -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' },

View File

@@ -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}`}
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>
)}
<StudentStreamImage
studentId={s.studentRkId}
kind="screen"
className="proctor-screen-image"
/>
{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,47 +434,40 @@ 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"
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<StudentStreamImage
studentId={zoomedStudent.studentRkId}
kind="screen"
style={{
maxWidth: '100%',
maxHeight: '65vh',
objectFit: 'contain',
borderRadius: '8px',
}}
/>
{showWebcamOverlay && (
<div
style={{
maxWidth: '100%',
maxHeight: '65vh',
objectFit: 'contain',
position: 'absolute',
bottom: '20px',
right: '20px',
width: '240px',
aspectRatio: '4/3',
borderRadius: '8px',
border: '2px solid #ffffff',
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.4)',
overflow: 'hidden',
backgroundColor: '#000',
}}
/>
{showWebcamOverlay && webcamFrames[zoomedStudent.studentRkId] && (
<div
style={{
position: 'absolute',
bottom: '20px',
right: '20px',
width: '240px',
aspectRatio: '4/3',
borderRadius: '8px',
border: '2px solid #ffffff',
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.4)',
overflow: 'hidden',
backgroundColor: '#000',
}}
>
<img
src={webcamFrames[zoomedStudent.studentRkId]}
alt="Zoomed 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>
)}
>
<StudentStreamImage
studentId={zoomedStudent.studentRkId}
kind="webcam"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
)}
</div>
</div>
</div>
</div>

View File

@@ -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"
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>
)}
<StudentStreamImage
studentId={studentId}
kind="screen"
className="live-frame screen-img"
style={{ transform: `scale(${screenZoom})` }}
/>
</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"
className="live-frame webcam-img"
style={{ transform: `scale(${webcamZoom})` }}
draggable={false}
/>
) : (
<div className="no-stream-placeholder"><p>Đang chờ webcam...</p></div>
)}
<StudentStreamImage
studentId={studentId}
kind="webcam"
className="live-frame webcam-img"
style={{ transform: `scale(${webcamZoom})` }}
/>
</div>
</div>
</div>

View 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}
/>
);
};

View File

@@ -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',
borderRadius: '6px',
color: '#92400e',
fontSize: '0.82rem',
display: 'flex',
alignItems: 'center',
gap: '0.35rem',
margin: '0',
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.
</div>
)}
<div style={{
padding: '0.65rem 0.85rem',
background: props.mode === 'exam' ? '#fef3c7' : '#eff6ff',
border: props.mode === 'exam' ? '1px solid #fcd34d' : '1px solid #bfdbfe',
borderRadius: '6px',
color: props.mode === 'exam' ? '#92400e' : '#1e40af',
fontSize: '0.82rem',
lineHeight: '1.4',
}}>
{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> 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}>
<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>
))}
{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 ${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)' }}>
{modeLabel(r.monitorMode)}
</td>
</tr>
);
})}
</tbody>
</table>
)}

View File

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

View File

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

View File

@@ -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,

View File

@@ -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"})

View File

@@ -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"`

View File

@@ -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 (
@@ -53,26 +59,30 @@ type offlineGrace struct {
}
type WsHub struct {
mu sync.RWMutex
students map[int64]*SocketClient
teachers map[string]*SocketClient
teachersByStaff map[uint][]string // staffId -> teacher connection addresses
subscribers map[int64][]string // studentId -> list of teacher connection addresses
grace map[int64]offlineGrace
graceTimers map[int64]*time.Timer
subscriberModes map[string]string // "teacherAddr_studentId" -> "grid"|"focus"
lastRelayed map[string]time.Time // "teacherAddr_studentId_event" -> time
mu sync.RWMutex
students map[int64]*SocketClient
teachers map[string]*SocketClient
teachersByStaff map[uint][]string // staffId -> teacher connection addresses
subscribers map[int64][]string // studentId -> list of teacher connection addresses
grace map[int64]offlineGrace
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{
students: make(map[int64]*SocketClient),
teachers: make(map[string]*SocketClient),
teachersByStaff: make(map[uint][]string),
subscribers: make(map[int64][]string),
grace: make(map[int64]offlineGrace),
graceTimers: make(map[int64]*time.Timer),
subscriberModes: make(map[string]string),
lastRelayed: make(map[string]time.Time),
students: make(map[int64]*SocketClient),
teachers: make(map[string]*SocketClient),
teachersByStaff: make(map[uint][]string),
subscribers: make(map[int64][]string),
grace: make(map[int64]offlineGrace),
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
@@ -568,7 +617,7 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
case string:
sID, _ = strconv.ParseInt(v, 10, 64)
}
// Nhận chế độ subscription (mặc định là "focus")
mode := "focus"
if mVal, ok := msg.Data["mode"].(string); ok && mVal != "" {
@@ -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
}

View File

@@ -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
View 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 (`&times;`).
- **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.