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) 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. // HandleBeforeClose — SV bấm X / Alt+F4: luôn hỏi xác nhận.
// Trả về false để cho phép đóng sau khi đã báo cáo. // 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) { func (a *App) HandleBeforeClose() (prevent bool) {
if !a.CheckLoginStatus() || !a.isMonitoringActive() {
a.clearRunLock()
return false
}
a.mu.Lock() a.mu.Lock()
mode := a.dashboard.MonitorMode mode := a.dashboard.MonitorMode
loggedIn := a.student != nil
a.mu.Unlock() a.mu.Unlock()
if !shouldRecordViolation(mode) {
a.clearRunLock() message := "Bạn có chắc muốn thoát Simple Care không?"
return false 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{ selection, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
Type: runtime.QuestionDialog, Type: runtime.QuestionDialog,
Title: "Xác nhận thoát", 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"}, Buttons: []string{"Có, thoát ứng dụng", "Không, tiếp tục"},
DefaultButton: "Không, tiếp tục", DefaultButton: "Không, tiếp tục",
CancelButton: "Không, tiếp tục",
}) })
if err != nil { 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)
a.tearDownBeforeQuit() return true
a.clearRunLock()
return false
} }
if selection == "Có, thoát ứng dụng" { if !isConfirmQuitSelection(selection) {
a.reportViolation("app_closed", "Sinh viên tự đóng ứng dụng khi đang giám sát ("+mode+")") return true // Không / Cancel → ở lại
a.tearDownBeforeQuit()
a.clearRunLock()
return false
} }
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). // 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"` MonitorMode string `json:"monitorMode"`
ClientAt string `json:"clientAt"` ClientAt string `json:"clientAt"`
StudentRkID int64 `json:"studentRkId"` 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ờ). // 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() a.mu.Lock()
studentID := int64(0) studentID := int64(0)
mode := a.dashboard.MonitorMode 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 { if a.student != nil {
studentID = a.student.StudentID studentID = a.student.StudentID
} }
@@ -406,6 +424,8 @@ func (a *App) markRunLock() {
payload, _ := json.Marshal(map[string]any{ payload, _ := json.Marshal(map[string]any{
"studentRkId": studentID, "studentRkId": studentID,
"monitorMode": mode, "monitorMode": mode,
"classRkId": classID,
"examRoomId": examRoomID,
"startedAt": time.Now().Format(time.RFC3339), "startedAt": time.Now().Format(time.RFC3339),
}) })
_ = os.WriteFile(a.runLockPath, payload, 0644) _ = os.WriteFile(a.runLockPath, payload, 0644)
@@ -425,6 +445,8 @@ func (a *App) detectUncleanShutdown() {
var meta struct { var meta struct {
StudentRkID int64 `json:"studentRkId"` StudentRkID int64 `json:"studentRkId"`
MonitorMode string `json:"monitorMode"` MonitorMode string `json:"monitorMode"`
ClassRkID int64 `json:"classRkId"`
ExamRoomID uint `json:"examRoomId"`
StartedAt string `json:"startedAt"` StartedAt string `json:"startedAt"`
} }
_ = json.Unmarshal(data, &meta) _ = json.Unmarshal(data, &meta)
@@ -448,6 +470,8 @@ func (a *App) detectUncleanShutdown() {
MonitorMode: meta.MonitorMode, MonitorMode: meta.MonitorMode,
ClientAt: time.Now().Format(time.RFC3339), ClientAt: time.Now().Format(time.RFC3339),
StudentRkID: meta.StudentRkID, StudentRkID: meta.StudentRkID,
ClassRkID: meta.ClassRkID,
ExamRoomID: meta.ExamRoomID,
}) })
} }
@@ -499,9 +523,11 @@ func (a *App) postViolation(v pendingViolation) bool {
"reason": v.Reason, "reason": v.Reason,
"monitorMode": v.MonitorMode, "monitorMode": v.MonitorMode,
"clientAt": v.ClientAt, "clientAt": v.ClientAt,
"classRkId": v.ClassRkID,
"examRoomId": v.ExamRoomID,
} }
bodyBytes, _ := json.Marshal(payload) 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)) resp, err := client.Post(API_BASE+"/api/student/report-violation", "application/json", bytes.NewBuffer(bodyBytes))
if err != nil { if err != nil {
log.Printf("[VIOLATION] report failed: %v", err) 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)) log.Printf("[VIOLATION] report status %d: %s", resp.StatusCode, string(body))
return false 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 return true
} }
@@ -521,6 +547,11 @@ func (a *App) reportViolation(kind, reason string) {
a.mu.Lock() a.mu.Lock()
studentID := int64(0) studentID := int64(0)
mode := a.dashboard.MonitorMode 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 { if a.student != nil {
studentID = a.student.StudentID studentID = a.student.StudentID
} }
@@ -534,6 +565,8 @@ func (a *App) reportViolation(kind, reason string) {
MonitorMode: mode, MonitorMode: mode,
ClientAt: time.Now().Format(time.RFC3339), ClientAt: time.Now().Format(time.RFC3339),
StudentRkID: studentID, StudentRkID: studentID,
ClassRkID: classID,
ExamRoomID: examRoomID,
} }
if !a.postViolation(v) { if !a.postViolation(v) {
a.enqueuePendingViolation(v) a.enqueuePendingViolation(v)

View File

@@ -387,6 +387,7 @@ export interface StudentViolationItem {
studentCode: string; studentCode: string;
fullName: string; fullName: string;
classRkId: number; classRkId: number;
examRoomId?: number;
kind: string; kind: string;
reason: string; reason: string;
monitorMode: string; monitorMode: string;
@@ -396,7 +397,7 @@ export interface StudentViolationItem {
export const VIOLATION_KIND_OPTIONS = [ export const VIOLATION_KIND_OPTIONS = [
{ value: '', label: 'Tất cả loại' }, { 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: 'unclean_shutdown', label: 'Tắt đột ngột' },
{ value: 'multi_monitor', label: 'Nhiều màn hình' }, { value: 'multi_monitor', label: 'Nhiều màn hình' },
{ value: 'user_switch', label: 'Đổi user' }, { value: 'user_switch', label: 'Đổi user' },

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState, useRef, useMemo } from 'react'; import React, { useEffect, useState, useRef, useMemo } from 'react';
import { getWsUrl, type ExamRoomStudent } from '../api'; import { type ExamRoomStudent } from '../api';
import { openStaffChat } from '../chatEvents'; import { openStaffChat } from '../chatEvents';
import { StudentStreamImage } from './StudentStreamImage';
interface ExamGridProctorProps { interface ExamGridProctorProps {
students: ExamRoomStudent[]; students: ExamRoomStudent[];
@@ -13,8 +14,6 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
onlineIds, onlineIds,
onSelectStudent, onSelectStudent,
}) => { }) => {
const [screenFrames, setScreenFrames] = useState<Record<number, string>>({});
const [webcamFrames, setWebcamFrames] = useState<Record<number, string>>({});
const [gridCols, setGridCols] = useState<number>(3); const [gridCols, setGridCols] = useState<number>(3);
const [showWebcamOverlay, setShowWebcamOverlay] = useState<boolean>(true); const [showWebcamOverlay, setShowWebcamOverlay] = useState<boolean>(true);
const [searchQuery, setSearchQuery] = useState<string>(''); const [searchQuery, setSearchQuery] = useState<string>('');
@@ -24,8 +23,6 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
const [pageSize, setPageSize] = useState<number | 'all'>(12); const [pageSize, setPageSize] = useState<number | 'all'>(12);
const [currentPage, setCurrentPage] = useState<number>(1); const [currentPage, setCurrentPage] = useState<number>(1);
const wsRef = useRef<WebSocket | null>(null);
const subscribedRef = useRef<Set<number>>(new Set());
const gridContainerRef = useRef<HTMLDivElement>(null); const gridContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -38,11 +35,6 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, []); }, []);
const studentIdsString = useMemo(
() => students.map((s) => s.studentRkId).join(','),
[students]
);
const filteredStudents = useMemo(() => { const filteredStudents = useMemo(() => {
return students.filter((s) => { return students.filter((s) => {
const matchesSearch = const matchesSearch =
@@ -71,160 +63,6 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
} }
}, [totalPages, currentPage]); }, [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) => { const handleOpenChat = (s: ExamRoomStudent, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
openStaffChat({ openStaffChat({
@@ -368,8 +206,6 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
> >
{pagedStudents.map((s) => { {pagedStudents.map((s) => {
const isOnline = onlineIds.includes(s.studentRkId); const isOnline = onlineIds.includes(s.studentRkId);
const screenFrame = screenFrames[s.studentRkId];
const webcamFrame = webcamFrames[s.studentRkId];
return ( return (
<div <div
@@ -397,27 +233,18 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
}} }}
style={{ cursor: 'zoom-in' }} style={{ cursor: 'zoom-in' }}
> >
{screenFrame ? ( <StudentStreamImage
<img studentId={s.studentRkId}
src={screenFrame} kind="screen"
alt={`Màn hình ${s.fullName}`} className="proctor-screen-image"
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"> <div className="proctor-webcam-overlay">
<img <StudentStreamImage
src={webcamFrame} studentId={s.studentRkId}
alt={`Webcam ${s.fullName}`} kind="webcam"
className="proctor-webcam-image" className="proctor-webcam-image"
draggable={false}
/> />
</div> </div>
)} )}
@@ -607,47 +434,40 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{screenFrames[zoomedStudent.studentRkId] ? ( <div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}> <StudentStreamImage
<img studentId={zoomedStudent.studentRkId}
src={screenFrames[zoomedStudent.studentRkId]} kind="screen"
alt="Zoomed Screen" style={{
maxWidth: '100%',
maxHeight: '65vh',
objectFit: 'contain',
borderRadius: '8px',
}}
/>
{showWebcamOverlay && (
<div
style={{ style={{
maxWidth: '100%', position: 'absolute',
maxHeight: '65vh', bottom: '20px',
objectFit: 'contain', right: '20px',
width: '240px',
aspectRatio: '4/3',
borderRadius: '8px', borderRadius: '8px',
border: '2px solid #ffffff',
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.4)',
overflow: 'hidden',
backgroundColor: '#000',
}} }}
/> >
{showWebcamOverlay && webcamFrames[zoomedStudent.studentRkId] && ( <StudentStreamImage
<div studentId={zoomedStudent.studentRkId}
style={{ kind="webcam"
position: 'absolute', style={{ width: '100%', height: '100%', objectFit: 'cover' }}
bottom: '20px', />
right: '20px', </div>
width: '240px', )}
aspectRatio: '4/3', </div>
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>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef, useCallback } from 'react'; import React, { useEffect, useState, useRef, useCallback } from 'react';
import { getWsUrl } from '../api'; import { StudentStreamImage } from './StudentStreamImage';
interface ProctorStreamPanelsProps { interface ProctorStreamPanelsProps {
studentId: number; studentId: number;
@@ -12,124 +12,16 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
studentId, studentId,
layout = 'focus', 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 [showWebcam, setShowWebcam] = useState(true);
const [screenZoomIdx, setScreenZoomIdx] = useState(2); // 1x const [screenZoomIdx, setScreenZoomIdx] = useState(2); // 1x
const [webcamZoomIdx, setWebcamZoomIdx] = useState(2); const [webcamZoomIdx, setWebcamZoomIdx] = useState(2);
const [isFullscreen, setIsFullscreen] = useState(false); 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 screenPanelRef = useRef<HTMLDivElement>(null);
const screenZoom = ZOOM_STEPS[screenZoomIdx]; const screenZoom = ZOOM_STEPS[screenZoomIdx];
const webcamZoom = ZOOM_STEPS[webcamZoomIdx]; 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(() => { useEffect(() => {
const onFsChange = () => { const onFsChange = () => {
setIsFullscreen(document.fullscreenElement === screenPanelRef.current); setIsFullscreen(document.fullscreenElement === screenPanelRef.current);
@@ -184,8 +76,8 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
return ( return (
<div className="proctor-stream-wrap"> <div className="proctor-stream-wrap">
<div className="proctor-stream-toolbar"> <div className="proctor-stream-toolbar">
<span className={`status-pill ${streaming || screenFrame || webcamFrame ? 'connected' : 'connecting'}`}> <span className="status-pill connected">
{streaming || screenFrame || webcamFrame ? '● Đang phát' : '○ Đang kết nối...'} Đang phát (HTTP)
</span> </span>
<div className="proctor-stream-actions"> <div className="proctor-stream-actions">
<button <button
@@ -198,10 +90,6 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
</div> </div>
</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-grid proctor-grid-${layout} ${!showWebcam ? 'proctor-grid--no-webcam' : ''}`}>
<div <div
className={`proctor-panel screen-panel ${isFullscreen ? 'screen-panel--fullscreen' : ''}`} className={`proctor-panel screen-panel ${isFullscreen ? 'screen-panel--fullscreen' : ''}`}
@@ -213,17 +101,12 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
</div> </div>
<div className="panel-body screen-body" onDoubleClick={toggleFullscreen} title="Double-click để phóng to"> <div className="panel-body screen-body" onDoubleClick={toggleFullscreen} title="Double-click để phóng to">
<div className="proctor-zoom-viewport"> <div className="proctor-zoom-viewport">
{screenFrame ? ( <StudentStreamImage
<img studentId={studentId}
src={screenFrame} kind="screen"
alt="Màn hình sinh viên" className="live-frame screen-img"
className="live-frame screen-img" style={{ transform: `scale(${screenZoom})` }}
style={{ transform: `scale(${screenZoom})` }} />
draggable={false}
/>
) : (
<div className="no-stream-placeholder"><p>Đang chờ màn hình...</p></div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -236,17 +119,12 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
</div> </div>
<div className="panel-body webcam-body"> <div className="panel-body webcam-body">
<div className="proctor-zoom-viewport proctor-zoom-viewport--webcam"> <div className="proctor-zoom-viewport proctor-zoom-viewport--webcam">
{webcamFrame ? ( <StudentStreamImage
<img studentId={studentId}
src={webcamFrame} kind="webcam"
alt="Webcam sinh viên" className="live-frame webcam-img"
className="live-frame webcam-img" style={{ transform: `scale(${webcamZoom})` }}
style={{ transform: `scale(${webcamZoom})` }} />
draggable={false}
/>
) : (
<div className="no-stream-placeholder"><p>Đang chờ webcam...</p></div>
)}
</div> </div>
</div> </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, VIOLATION_KIND_OPTIONS,
type StudentViolationItem, type StudentViolationItem,
} from '../api'; } from '../api';
import { kindLabel } from '../hooks/useStaffChatSocket'; import { kindLabel, onStudentViolation } from '../hooks/useStaffChatSocket';
type Props = type Props =
| { mode: 'class'; classId: number } | { mode: 'class'; classId: number }
@@ -24,6 +24,14 @@ function formatTime(iso?: string): string {
return d.toLocaleString('vi-VN'); 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) => { export const ViolationsPanel = (props: Props) => {
const [date, setDate] = useState(todayLocal); const [date, setDate] = useState(todayLocal);
const [kind, setKind] = useState(''); const [kind, setKind] = useState('');
@@ -52,25 +60,36 @@ export const ViolationsPanel = (props: Props) => {
void load(); void load();
}, [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 ( return (
<div className="session-logs-panel" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', height: '100%' }}> <div className="session-logs-panel" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', height: '100%' }}>
{props.mode === 'exam' && ( <div style={{
<div style={{ padding: '0.65rem 0.85rem',
padding: '0.65rem 0.85rem', background: props.mode === 'exam' ? '#fef3c7' : '#eff6ff',
background: '#fef3c7', border: props.mode === 'exam' ? '1px solid #fcd34d' : '1px solid #bfdbfe',
border: '1px solid #fcd34d', borderRadius: '6px',
borderRadius: '6px', color: props.mode === 'exam' ? '#92400e' : '#1e40af',
color: '#92400e', fontSize: '0.82rem',
fontSize: '0.82rem', lineHeight: '1.4',
display: 'flex', }}>
alignItems: 'center', {props.mode === 'exam'
gap: '0.35rem', ? '⚠ 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.'
margin: '0', : ' 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.'}
lineHeight: '1.4' </div>
}}>
<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 className="attendance-toolbar" style={{ marginBottom: 0, flexWrap: 'wrap' }}> <div className="attendance-toolbar" style={{ marginBottom: 0, flexWrap: 'wrap' }}>
<label className="attendance-field"> <label className="attendance-field">
<span>Ngày</span> <span>Ngày</span>
@@ -113,28 +132,36 @@ export const ViolationsPanel = (props: Props) => {
<th> SV</th> <th> SV</th>
<th>Loại</th> <th>Loại</th>
<th>Chi tiết</th> <th>Chi tiết</th>
<th>Chế đ</th> <th>Ngữ cảnh</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((r) => ( {rows.map((r) => {
<tr key={r.id}> const isClose = r.kind === 'app_closed' || r.kind === 'unclean_shutdown';
<td style={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.8rem' }}> return (
{formatTime(r.createdAt || r.clientAt)} <tr key={r.id} style={isClose ? { background: 'rgba(239, 68, 68, 0.06)' } : undefined}>
</td> <td style={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.8rem' }}>
<td style={{ fontWeight: 600 }}>{r.fullName || '—'}</td> {formatTime(r.createdAt || r.clientAt)}
<td><code>{r.studentCode || r.studentRkId}</code></td> </td>
<td> <td style={{ fontWeight: 600 }}>{r.fullName || '—'}</td>
<span className="badge badge-warning" style={{ fontSize: '0.72rem' }}> <td><code>{r.studentCode || r.studentRkId}</code></td>
{kindLabel(r.kind)} <td>
</span> <span
</td> className={`badge ${isClose ? 'badge-danger' : 'badge-warning'}`}
<td style={{ maxWidth: 360, fontSize: '0.85rem' }} title={r.reason}> style={{ fontSize: '0.72rem' }}
{r.reason} >
</td> {kindLabel(r.kind)}
<td style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{r.monitorMode || '—'}</td> </span>
</tr> </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> </tbody>
</table> </table>
)} )}

View File

@@ -13,6 +13,7 @@ export type StudentViolationEvent = {
reason: string; reason: string;
monitorMode?: string; monitorMode?: string;
classId?: number; classId?: number;
examRoomId?: number;
}; };
const chatHandlers = new Set<ChatIncomingHandler>(); const chatHandlers = new Set<ChatIncomingHandler>();
@@ -41,7 +42,7 @@ function scheduleBackoff(attempt: number): number {
export function kindLabel(kind: string): string { export function kindLabel(kind: string): string {
switch (kind) { 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 'unclean_shutdown': return 'Tắt đột ngột';
case 'multi_monitor': return 'Nhiều màn hình'; case 'multi_monitor': return 'Nhiều màn hình';
case 'user_switch': return 'Đổi user'; case 'user_switch': return 'Đổi user';
@@ -123,6 +124,7 @@ export function StaffChatSocket() {
reason: String(payload.data?.reason || ''), reason: String(payload.data?.reason || ''),
monitorMode: payload.data?.monitorMode || '', monitorMode: payload.data?.monitorMode || '',
classId: Number(payload.data?.classId ?? 0) || undefined, classId: Number(payload.data?.classId ?? 0) || undefined,
examRoomId: Number(payload.data?.examRoomId ?? 0) || undefined,
})); }));
return; return;
} }

View File

@@ -843,6 +843,12 @@ input:checked + .slider:before {
border: 1px solid rgba(180, 83, 9, 0.25); 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 { .courses-tag-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -744,6 +744,8 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
Reason string `json:"reason"` Reason string `json:"reason"`
MonitorMode string `json:"monitorMode"` MonitorMode string `json:"monitorMode"`
ClientAt string `json:"clientAt"` // RFC3339 optional ClientAt string `json:"clientAt"` // RFC3339 optional
ClassRkID int64 `json:"classRkId"`
ExamRoomID uint `json:"examRoomId"`
} }
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"}) 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 == "" { if reason == "" {
reason = kind 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() clientAt := time.Now()
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.ClientAt)); err == nil { if t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.ClientAt)); err == nil {
clientAt = t clientAt = t
@@ -772,6 +786,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
row := models.StudentViolation{ row := models.StudentViolation{
StudentRkID: req.StudentRkID, StudentRkID: req.StudentRkID,
ClassRkID: classID, ClassRkID: classID,
ExamRoomID: examRoomID,
Kind: kind, Kind: kind,
Reason: reason, Reason: reason,
MonitorMode: monitorMode, MonitorMode: monitorMode,
@@ -802,6 +817,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
"studentName": studentName, "studentName": studentName,
"studentCode": studentCode, "studentCode": studentCode,
"classId": classID, "classId": classID,
"examRoomId": examRoomID,
"kind": kind, "kind": kind,
"reason": reason, "reason": reason,
"monitorMode": row.MonitorMode, "monitorMode": row.MonitorMode,
@@ -809,7 +825,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
"createdAt": row.CreatedAt.Format(time.RFC3339), "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"` StudentCode string `json:"studentCode"`
FullName string `json:"fullName"` FullName string `json:"fullName"`
ClassRkID int64 `json:"classRkId"` ClassRkID int64 `json:"classRkId"`
ExamRoomID uint `json:"examRoomId"`
Kind string `json:"kind"` Kind string `json:"kind"`
Reason string `json:"reason"` Reason string `json:"reason"`
MonitorMode string `json:"monitorMode"` 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()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
if len(mappings) == 0 { 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)) studentIDs := make([]int64, 0, len(mappings))
for _, m := range mappings { for _, m := range mappings {
@@ -866,7 +883,11 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
} }
dayEnd := dayStart.Add(24 * time.Hour) 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 != "" { if kindFilter != "" {
q = q.Where("kind = ?", kindFilter) q = q.Where("kind = ?", kindFilter)
} }
@@ -878,12 +899,16 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
out := make([]studentViolationItem, 0, len(rows)) out := make([]studentViolationItem, 0, len(rows))
for _, r := range rows { for _, r := range rows {
st := nameByID[r.StudentRkID] st := nameByID[r.StudentRkID]
if st.RkID == 0 {
_ = db.Where("rk_id = ?", r.StudentRkID).First(&st).Error
}
out = append(out, studentViolationItem{ out = append(out, studentViolationItem{
ID: r.ID, ID: r.ID,
StudentRkID: r.StudentRkID, StudentRkID: r.StudentRkID,
StudentCode: st.StudentCode, StudentCode: st.StudentCode,
FullName: st.FullName, FullName: st.FullName,
ClassRkID: r.ClassRkID, ClassRkID: r.ClassRkID,
ExamRoomID: r.ExamRoomID,
Kind: r.Kind, Kind: r.Kind,
Reason: r.Reason, Reason: r.Reason,
MonitorMode: r.MonitorMode, 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()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
if len(roster) == 0 { 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)) studentIDs := make([]int64, 0, len(roster))
for _, s := range roster { for _, s := range roster {
@@ -933,7 +958,11 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
} }
dayEnd := dayStart.Add(24 * time.Hour) 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 != "" { if kindFilter != "" {
q = q.Where("kind = ?", kindFilter) q = q.Where("kind = ?", kindFilter)
} }
@@ -951,6 +980,7 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
StudentCode: st.StudentCode, StudentCode: st.StudentCode,
FullName: st.FullName, FullName: st.FullName,
ClassRkID: r.ClassRkID, ClassRkID: r.ClassRkID,
ExamRoomID: r.ExamRoomID,
Kind: r.Kind, Kind: r.Kind,
Reason: r.Reason, Reason: r.Reason,
MonitorMode: r.MonitorMode, MonitorMode: r.MonitorMode,

View File

@@ -11,10 +11,16 @@ import (
func RequireStaff() fiber.Handler { func RequireStaff() fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
header := c.Get("Authorization") 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"}) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
} }
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
claims, err := auth.ParseToken(token) claims, err := auth.ParseToken(token)
if err != nil { if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"}) 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"` CreatedAt time.Time `json:"createdAt"`
StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"` StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"`
ClassRkID int64 `gorm:"column:class_rk_id;index" json:"classRkId"` 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 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"` Reason string `gorm:"column:reason;type:text" json:"reason"`
MonitorMode string `gorm:"column:monitor_mode;size:32" json:"monitorMode"` MonitorMode string `gorm:"column:monitor_mode;size:32" json:"monitorMode"`

View File

@@ -1,15 +1,21 @@
package websocket package websocket
import ( import (
"bufio"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
internalDb "server/internal/db"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/websocket/v2" "github.com/gofiber/websocket/v2"
"gorm.io/gorm" "gorm.io/gorm"
internalDb "server/internal/db"
) )
const ( const (
@@ -53,26 +59,30 @@ type offlineGrace struct {
} }
type WsHub struct { type WsHub struct {
mu sync.RWMutex mu sync.RWMutex
students map[int64]*SocketClient students map[int64]*SocketClient
teachers map[string]*SocketClient teachers map[string]*SocketClient
teachersByStaff map[uint][]string // staffId -> teacher connection addresses teachersByStaff map[uint][]string // staffId -> teacher connection addresses
subscribers map[int64][]string // studentId -> list of teacher connection addresses subscribers map[int64][]string // studentId -> list of teacher connection addresses
grace map[int64]offlineGrace grace map[int64]offlineGrace
graceTimers map[int64]*time.Timer graceTimers map[int64]*time.Timer
subscriberModes map[string]string // "teacherAddr_studentId" -> "grid"|"focus" subscriberModes map[string]string // "teacherAddr_studentId" -> "grid"|"focus"
lastRelayed map[string]time.Time // "teacherAddr_studentId_event" -> time 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{ var Hub = &WsHub{
students: make(map[int64]*SocketClient), students: make(map[int64]*SocketClient),
teachers: make(map[string]*SocketClient), teachers: make(map[string]*SocketClient),
teachersByStaff: make(map[uint][]string), teachersByStaff: make(map[uint][]string),
subscribers: make(map[int64][]string), subscribers: make(map[int64][]string),
grace: make(map[int64]offlineGrace), grace: make(map[int64]offlineGrace),
graceTimers: make(map[int64]*time.Timer), graceTimers: make(map[int64]*time.Timer),
subscriberModes: make(map[string]string), subscriberModes: make(map[string]string),
lastRelayed: make(map[string]time.Time), 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 { func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
@@ -403,6 +413,45 @@ func (h *WsHub) RelayFrameRaw(studentID int64, event string, rawImageBuffer json
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() 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] teachersList, exists := h.subscribers[studentID]
if !exists || len(teachersList) == 0 { if !exists || len(teachersList) == 0 {
return return
@@ -568,7 +617,7 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
case string: case string:
sID, _ = strconv.ParseInt(v, 10, 64) sID, _ = strconv.ParseInt(v, 10, 64)
} }
// Nhận chế độ subscription (mặc định là "focus") // Nhận chế độ subscription (mặc định là "focus")
mode := "focus" mode := "focus"
if mVal, ok := msg.Data["mode"].(string); ok && mVal != "" { 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 // Students endpoints
staff.Get("/students", handlers.ListAllStudentsHandler(gormDB)) staff.Get("/students", handlers.ListAllStudentsHandler(gormDB))
staff.Get("/students/:studentId/stream/screen", internalWs.GetStudentScreenStreamHandler)
staff.Get("/students/:studentId/stream/webcam", internalWs.GetStudentWebcamStreamHandler)
// Sync endpoints // Sync endpoints
staff.Post("/sync/classes/start", handlers.StartClassesSyncHandler(gormDB, qldtClient, classesJob)) 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.