Compare commits

...

2 Commits

Author SHA1 Message Date
8803cd622a tam 2
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m25s
2026-07-13 09:04:09 +07:00
719704f19e tam 2026-07-13 08:12:04 +07:00
22 changed files with 1525 additions and 202 deletions

View File

@@ -125,8 +125,13 @@ type App struct {
sessionPath string sessionPath string
statsPath string statsPath string
webviewDataPath string webviewDataPath string
pendingViolPath string
runLockPath string
wsConn *websocket.Conn wsConn *websocket.Conn
wsConnected bool wsConnected bool
wsConnecting bool
wsBackoff time.Duration
wsWriteMu sync.Mutex
wifiSSID string wifiSSID string
wifiBSSID string wifiBSSID string
allowedApps string allowedApps string
@@ -152,7 +157,6 @@ type App struct {
chatUnread int chatUnread int
replyStaffID uint replyStaffID uint
fetchAppsMu sync.Mutex fetchAppsMu sync.Mutex
wsWriteMu sync.Mutex
} }
type LocalStats struct { type LocalStats struct {
@@ -216,6 +220,8 @@ func NewApp() *App {
sessionPath: filepath.Join(appDir, "student_session.json"), sessionPath: filepath.Join(appDir, "student_session.json"),
statsPath: filepath.Join(appDir, "student_stats.json"), statsPath: filepath.Join(appDir, "student_stats.json"),
webviewDataPath: webviewDir, webviewDataPath: webviewDir,
pendingViolPath: filepath.Join(appDir, "pending_violations.json"),
runLockPath: filepath.Join(appDir, "monitoring.lock"),
lastSyncTime: time.Now(), lastSyncTime: time.Now(),
expectingLogin: false, expectingLogin: false,
} }
@@ -226,6 +232,10 @@ func (a *App) startup(ctx context.Context) {
a.loadSession() a.loadSession()
a.loadStats() a.loadStats()
// Task Manager / kill process lần trước → còn file lock → báo unclean_shutdown
a.detectUncleanShutdown()
go a.flushPendingViolations()
// Request Location Access (macOS) // Request Location Access (macOS)
winapi.RequestLocationAccess() winapi.RequestLocationAccess()
winapi.RequestCameraAndMicAccess() winapi.RequestCameraAndMicAccess()
@@ -268,10 +278,35 @@ func (a *App) startup(ctx context.Context) {
go a.authStorageScanner() go a.authStorageScanner()
} }
func (a *App) handleGuardViolation(reason string) { func (a *App) handleGuardViolation(kind, reason string) {
if kind == "" {
kind = "guard"
}
a.reportViolation(kind, reason)
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.
// Trả về false để cho phép đóng sau khi đã báo cáo.
func (a *App) HandleBeforeClose() (prevent bool) {
if !a.CheckLoginStatus() || !a.isMonitoringActive() {
a.clearRunLock()
return false
}
a.mu.Lock()
mode := a.dashboard.MonitorMode
a.mu.Unlock()
if mode == "" || mode == "not_configured" {
a.clearRunLock()
return false
}
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
}
// 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).
func (a *App) tearDownBeforeQuit() { func (a *App) tearDownBeforeQuit() {
a.mu.Lock() a.mu.Lock()
@@ -287,6 +322,7 @@ func (a *App) tearDownBeforeQuit() {
a.stopWebcamStream() a.stopWebcamStream()
a.disconnectWS() a.disconnectWS()
guard.Stop() guard.Stop()
a.clearRunLock()
} }
func (a *App) isMonitoringActive() bool { func (a *App) isMonitoringActive() bool {
@@ -315,6 +351,162 @@ func (a *App) showQuitDialog(title, message string) {
runtime.Quit(a.ctx) runtime.Quit(a.ctx)
} }
type pendingViolation struct {
Kind string `json:"kind"`
Reason string `json:"reason"`
MonitorMode string `json:"monitorMode"`
ClientAt string `json:"clientAt"`
StudentRkID int64 `json:"studentRkId"`
}
func (a *App) markRunLock() {
a.mu.Lock()
studentID := int64(0)
mode := a.dashboard.MonitorMode
if a.student != nil {
studentID = a.student.StudentID
}
a.mu.Unlock()
if studentID <= 0 || mode == "" || mode == "not_configured" {
return
}
payload, _ := json.Marshal(map[string]any{
"studentRkId": studentID,
"monitorMode": mode,
"startedAt": time.Now().Format(time.RFC3339),
})
_ = os.WriteFile(a.runLockPath, payload, 0644)
}
func (a *App) clearRunLock() {
_ = os.Remove(a.runLockPath)
}
func (a *App) detectUncleanShutdown() {
data, err := os.ReadFile(a.runLockPath)
if err != nil {
return
}
_ = os.Remove(a.runLockPath)
var meta struct {
StudentRkID int64 `json:"studentRkId"`
MonitorMode string `json:"monitorMode"`
StartedAt string `json:"startedAt"`
}
_ = json.Unmarshal(data, &meta)
if meta.StudentRkID <= 0 {
a.mu.Lock()
if a.student != nil {
meta.StudentRkID = a.student.StudentID
}
a.mu.Unlock()
}
if meta.StudentRkID <= 0 {
return
}
reason := "Ứng dụng bị tắt đột ngột (Task Manager / kill process) khi đang giám sát"
if meta.MonitorMode != "" {
reason += " (" + meta.MonitorMode + ")"
}
a.enqueuePendingViolation(pendingViolation{
Kind: "unclean_shutdown",
Reason: reason,
MonitorMode: meta.MonitorMode,
ClientAt: time.Now().Format(time.RFC3339),
StudentRkID: meta.StudentRkID,
})
}
func (a *App) enqueuePendingViolation(v pendingViolation) {
list := a.loadPendingViolations()
list = append(list, v)
raw, _ := json.Marshal(list)
_ = os.WriteFile(a.pendingViolPath, raw, 0644)
}
func (a *App) loadPendingViolations() []pendingViolation {
data, err := os.ReadFile(a.pendingViolPath)
if err != nil {
return nil
}
var list []pendingViolation
if json.Unmarshal(data, &list) != nil {
return nil
}
return list
}
func (a *App) flushPendingViolations() {
list := a.loadPendingViolations()
if len(list) == 0 {
return
}
remaining := make([]pendingViolation, 0)
for _, v := range list {
if !a.postViolation(v) {
remaining = append(remaining, v)
}
}
if len(remaining) == 0 {
_ = os.Remove(a.pendingViolPath)
return
}
raw, _ := json.Marshal(remaining)
_ = os.WriteFile(a.pendingViolPath, raw, 0644)
}
func (a *App) postViolation(v pendingViolation) bool {
if v.StudentRkID <= 0 {
return true
}
payload := map[string]any{
"studentRkId": v.StudentRkID,
"kind": v.Kind,
"reason": v.Reason,
"monitorMode": v.MonitorMode,
"clientAt": v.ClientAt,
}
bodyBytes, _ := json.Marshal(payload)
client := http.Client{Timeout: 5 * 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)
return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
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)
return true
}
func (a *App) reportViolation(kind, reason string) {
a.mu.Lock()
studentID := int64(0)
mode := a.dashboard.MonitorMode
if a.student != nil {
studentID = a.student.StudentID
}
a.mu.Unlock()
if studentID <= 0 {
return
}
v := pendingViolation{
Kind: kind,
Reason: reason,
MonitorMode: mode,
ClientAt: time.Now().Format(time.RFC3339),
StudentRkID: studentID,
}
if !a.postViolation(v) {
a.enqueuePendingViolation(v)
}
}
// startLocalServer khởi chạy server lắng nghe callback nhận thông tin sinh viên từ webview // startLocalServer khởi chạy server lắng nghe callback nhận thông tin sinh viên từ webview
func (a *App) startLocalServer() { func (a *App) startLocalServer() {
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -392,6 +584,16 @@ func (a *App) startLocalServer() {
}() }()
w.Write([]byte("ok")) w.Write([]byte("ok"))
}) })
mux.HandleFunc("/exam-clear-cache", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
return
}
// Chỉ purge disk — JS trên trang exam-view tự clear storage + reload iframe.
go a.purgeWebViewDiskCache()
w.Write([]byte("ok"))
})
go func() { go func() {
_ = server.ListenAndServe() _ = server.ListenAndServe()
@@ -408,26 +610,95 @@ const examViewHTML = `<!DOCTYPE html>
<style> <style>
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
body{font-family:Segoe UI,system-ui,sans-serif;background:#1e293b;color:#f8fafc;height:100vh;display:flex;flex-direction:column} body{font-family:Segoe UI,system-ui,sans-serif;background:#1e293b;color:#f8fafc;height:100vh;display:flex;flex-direction:column}
.bar{display:flex;align-items:center;gap:12px;padding:10px 14px;background:#0f172a;border-bottom:1px solid #334155;flex-shrink:0} .bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#0f172a;border-bottom:1px solid #334155;flex-shrink:0}
.bar button{background:#7c3aed;color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:14px;cursor:pointer;font-weight:600} .bar-title{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:auto;min-width:0}
.bar button:hover{background:#6d28d9} .bar button{border:none;border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer;font-weight:600;white-space:nowrap}
.bar span{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .btn-back{background:#7c3aed;color:#fff}
.btn-back:hover{background:#6d28d9}
.btn-refresh{background:#0ea5e9;color:#fff}
.btn-refresh:hover{background:#0284c7}
.btn-cache{background:#f59e0b;color:#111}
.btn-cache:hover{background:#d97706}
.btn-cache:disabled,.btn-refresh:disabled{opacity:.65;cursor:wait}
.hint{font-size:11px;color:#94a3b8;flex-shrink:0}
.frame-wrap{flex:1;min-height:0;background:#fff} .frame-wrap{flex:1;min-height:0;background:#fff}
iframe{width:100%%;height:100%%;border:0;display:block} iframe{width:100%%;height:100%%;border:0;display:block}
</style> </style>
</head> </head>
<body> <body>
<div class="bar"> <div class="bar">
<button type="button" onclick="goBack()">← Quay lại</button> <button type="button" class="btn-back" onclick="goBack()">← Quay lại</button>
<span>%s</span> <span class="bar-title">%s</span>
<span class="hint">Nếu trang lỗi / trắng: F5 hoặc Xóa cache</span>
<button type="button" class="btn-refresh" id="btn-refresh" onclick="refreshExam()">⟳ F5 Làm mới</button>
<button type="button" class="btn-cache" id="btn-cache" onclick="clearExamCache()">🗑 Xóa cache</button>
</div> </div>
<div class="frame-wrap"> <div class="frame-wrap">
<iframe src="%s" title="exam-content"></iframe> <iframe id="exam-frame" src="%s" title="exam-content"></iframe>
</div> </div>
<script> <script>
function goBack(){ function goBack(){
fetch('http://127.0.0.1:34115/exam-close').catch(function(){}); fetch('http://127.0.0.1:34115/exam-close').catch(function(){});
} }
function refreshExam(){
var f = document.getElementById('exam-frame');
if (!f) return;
var btn = document.getElementById('btn-refresh');
if (btn) btn.disabled = true;
try {
f.contentWindow.location.reload();
} catch (e) {
var src = f.src;
f.src = 'about:blank';
setTimeout(function(){ f.src = src; }, 50);
}
setTimeout(function(){ if (btn) btn.disabled = false; }, 800);
}
async function clearExamCache(){
var btn = document.getElementById('btn-cache');
if (btn) btn.disabled = true;
try {
if (window.caches) {
var keys = await caches.keys();
await Promise.all(keys.map(function(k){ return caches.delete(k); }));
}
} catch (e) {}
try { localStorage.clear(); } catch (e) {}
try { sessionStorage.clear(); } catch (e) {}
try {
var f = document.getElementById('exam-frame');
if (f && f.contentWindow) {
try { f.contentWindow.localStorage.clear(); } catch (e) {}
try { f.contentWindow.sessionStorage.clear(); } catch (e) {}
try {
if (f.contentWindow.caches) {
var ikeys = await f.contentWindow.caches.keys();
await Promise.all(ikeys.map(function(k){ return f.contentWindow.caches.delete(k); }));
}
} catch (e) {}
}
} catch (e) {}
try {
await fetch('http://127.0.0.1:34115/exam-clear-cache');
} catch (e) {}
var f = document.getElementById('exam-frame');
if (f) {
try {
var u = new URL(f.src, window.location.href);
u.searchParams.set('_sc_cb', String(Date.now()));
f.src = u.toString();
} catch (e) {
refreshExam();
}
}
setTimeout(function(){ if (btn) btn.disabled = false; }, 1200);
}
document.addEventListener('keydown', function(e){
if (e.key === 'F5') {
e.preventDefault();
refreshExam();
}
});
</script> </script>
</body> </body>
</html>` </html>`
@@ -578,6 +849,7 @@ func (a *App) Logout() {
blocker.Instance.Stop() blocker.Instance.Stop()
a.disconnectWS() a.disconnectWS()
a.stopScreenshotStream() a.stopScreenshotStream()
a.stopWebcamStream()
guard.SuppressFor(5 * time.Second) guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, "window.location.href = 'https://portal.rikkei.edu.vn/dangnhap'") runtime.WindowExecJS(a.ctx, "window.location.href = 'https://portal.rikkei.edu.vn/dangnhap'")
@@ -1016,6 +1288,7 @@ func (a *App) rejectUnauthorizedWifi(ssid, bssid string) {
a.wifiRejected = true a.wifiRejected = true
a.mu.Unlock() a.mu.Unlock()
a.reportViolation("wifi", fmt.Sprintf("WiFi không được phép: %s (%s)", ssid, bssid))
a.showQuitDialog( a.showQuitDialog(
"WiFi không được phép", "WiFi không được phép",
fmt.Sprintf("Điểm phát WiFi \"%s\" (%s) không được phép.\n\nCó thể là mạng giả mạo (hotspot trùng tên). Hãy kết nối đúng WiFi của trường rồi mở lại ứng dụng.", ssid, bssid), fmt.Sprintf("Điểm phát WiFi \"%s\" (%s) không được phép.\n\nCó thể là mạng giả mạo (hotspot trùng tên). Hãy kết nối đúng WiFi của trường rồi mở lại ứng dụng.", ssid, bssid),
@@ -1131,33 +1404,102 @@ func (a *App) connectWS() {
return return
} }
a.mu.Lock() a.mu.Lock()
if a.wsConnected || a.student == nil { if a.wsConnected || a.wsConnecting || a.student == nil {
a.mu.Unlock() a.mu.Unlock()
return return
} }
a.wsConnecting = true
student := a.student student := a.student
classID := a.dashboard.ClassRkID classID := a.dashboard.ClassRkID
if classID <= 0 { if classID <= 0 {
classID = student.SystemID classID = student.SystemID
} }
backoff := a.wsBackoff
if backoff <= 0 {
backoff = 500 * time.Millisecond
}
a.mu.Unlock() a.mu.Unlock()
wsUrl := fmt.Sprintf("%s/ws?role=student&studentId=%d&classId=%d", getWsUrl(API_BASE), student.StudentID, classID) wsUrl := fmt.Sprintf("%s/ws?role=student&studentId=%d&classId=%d", getWsUrl(API_BASE), student.StudentID, classID)
dialer := websocket.Dialer{HandshakeTimeout: 4 * time.Second} dialer := websocket.Dialer{HandshakeTimeout: 4 * time.Second}
conn, _, err := dialer.Dial(wsUrl, nil) conn, _, err := dialer.Dial(wsUrl, nil)
if err != nil { if err != nil {
a.mu.Lock()
a.wsConnecting = false
delay := backoff
next := delay * 2
if next > 10*time.Second {
next = 10 * time.Second
}
a.wsBackoff = next
a.mu.Unlock()
log.Printf("[WS] Dial failed: %v — retry in %v", err, delay)
time.AfterFunc(delay, func() {
if a.isMonitoringActive() && a.CheckLoginStatus() {
a.connectWS()
}
})
return return
} }
_ = conn.SetReadDeadline(time.Now().Add(45 * time.Second))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(45 * time.Second))
})
a.mu.Lock() a.mu.Lock()
a.wsConn = conn a.wsConn = conn
a.wsConnected = true a.wsConnected = true
a.wsConnecting = false
a.wsBackoff = 500 * time.Millisecond
a.mu.Unlock() a.mu.Unlock()
log.Println("[WS] Connected to proctor websocket hub.") log.Println("[WS] Connected to proctor websocket hub.")
a.markRunLock()
pingDone := make(chan struct{})
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-pingDone:
return
case <-ticker.C:
a.wsWriteMu.Lock()
err := conn.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(5*time.Second))
a.wsWriteMu.Unlock()
if err != nil {
return
}
// App-level ping (một số proxy không forward WS control frames đúng).
_ = a.safeWriteWS(map[string]any{"event": "client:ping", "data": map[string]any{}})
}
}
}()
go func() { go func() {
defer a.disconnectWS() defer close(pingDone)
defer func() {
a.disconnectWS(conn)
a.mu.Lock()
delay := a.wsBackoff
if delay <= 0 {
delay = 500 * time.Millisecond
}
next := delay * 2
if next > 10*time.Second {
next = 10 * time.Second
}
a.wsBackoff = next
a.mu.Unlock()
log.Printf("[WS] Disconnected — reconnect in %v", delay)
time.AfterFunc(delay, func() {
if a.isMonitoringActive() && a.CheckLoginStatus() {
a.connectWS()
}
})
}()
for { for {
var msg struct { var msg struct {
Event string `json:"event"` Event string `json:"event"`
@@ -1168,6 +1510,10 @@ func (a *App) connectWS() {
log.Printf("[WS] ReadJSON error: %v", err) log.Printf("[WS] ReadJSON error: %v", err)
break break
} }
_ = conn.SetReadDeadline(time.Now().Add(45 * time.Second))
if msg.Event == "client:pong" {
continue
}
log.Printf("[WS] Received event: %s", msg.Event) log.Printf("[WS] Received event: %s", msg.Event)
switch msg.Event { switch msg.Event {
@@ -1214,15 +1560,29 @@ func (a *App) connectWS() {
}() }()
} }
func (a *App) disconnectWS() { func (a *App) disconnectWS(expectedConn ...*websocket.Conn) {
a.mu.Lock() var shouldStopStreams bool
defer a.mu.Unlock()
a.mu.Lock()
if len(expectedConn) > 0 && expectedConn[0] != nil {
if a.wsConn != expectedConn[0] {
a.mu.Unlock()
return
}
}
if a.wsConn != nil { if a.wsConn != nil {
_ = a.wsConn.Close() _ = a.wsConn.Close()
a.wsConn = nil a.wsConn = nil
} }
a.wsConnected = false a.wsConnected = false
a.wsConnecting = false
shouldStopStreams = a.isStreamingSc || a.isStreamingCam
a.mu.Unlock()
if shouldStopStreams {
a.stopScreenshotStream()
a.stopWebcamStream()
}
} }
func (a *App) startScreenshotStream() { func (a *App) startScreenshotStream() {
@@ -1501,6 +1861,83 @@ func (a *App) ReturnToDashboard() {
runtime.WindowReloadApp(a.ctx) runtime.WindowReloadApp(a.ctx)
} }
// ReloadExamPage — F5: làm mới trang / iframe đang mở (trắc nghiệm).
func (a *App) ReloadExamPage() {
guard.SuppressFor(3 * time.Second)
runtime.WindowExecJS(a.ctx, `
(function(){
try {
if (typeof window.refreshExam === 'function') { window.refreshExam(); return; }
var f = document.getElementById('exam-frame');
if (f) {
try { f.contentWindow.location.reload(); }
catch (e) { var s = f.src; f.src = 'about:blank'; setTimeout(function(){ f.src = s; }, 50); }
return;
}
} catch (e) {}
location.reload();
})();
`)
}
// ClearExamBrowserCache — xóa cache WebView (storage + disk cache) rồi làm mới trang thi.
func (a *App) ClearExamBrowserCache() {
guard.SuppressFor(8 * time.Second)
a.purgeWebViewDiskCache()
runtime.WindowExecJS(a.ctx, `
(async function(){
try {
if (window.caches) {
var keys = await caches.keys();
await Promise.all(keys.map(function(k){ return caches.delete(k); }));
}
} catch (e) {}
try { localStorage.clear(); } catch (e) {}
try { sessionStorage.clear(); } catch (e) {}
try {
var f = document.getElementById('exam-frame');
if (f) {
try { f.contentWindow.localStorage.clear(); } catch (e) {}
try { f.contentWindow.sessionStorage.clear(); } catch (e) {}
try {
var u = new URL(f.src, window.location.href);
u.searchParams.set('_sc_cb', String(Date.now()));
f.src = u.toString();
return;
} catch (e) {
try { f.contentWindow.location.reload(); } catch (e2) {
var s = f.src; f.src = 'about:blank'; setTimeout(function(){ f.src = s; }, 50);
}
return;
}
}
} catch (e) {}
location.reload();
})();
`)
}
func (a *App) purgeWebViewDiskCache() {
if a.webviewDataPath == "" {
return
}
subs := []string{
filepath.Join("EBWebView", "Default", "Cache"),
filepath.Join("EBWebView", "Default", "Code Cache"),
filepath.Join("EBWebView", "Default", "GPUCache"),
filepath.Join("EBWebView", "Default", "Service Worker", "CacheStorage"),
filepath.Join("EBWebView", "Default", "Service Worker", "ScriptCache"),
}
for _, sub := range subs {
path := filepath.Join(a.webviewDataPath, sub)
if err := os.RemoveAll(path); err != nil {
log.Printf("[CACHE] purge %s: %v", path, err)
} else {
log.Printf("[CACHE] purged %s", path)
}
}
}
func isLocalExamURL(raw string) bool { func isLocalExamURL(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw)) u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" { if err != nil || u.Host == "" {
@@ -1511,11 +1948,17 @@ func isLocalExamURL(raw string) bool {
} }
func (a *App) openExamWebView(targetURL, title string) error { func (a *App) openExamWebView(targetURL, title string) error {
return a.openExamWebViewOpts(targetURL, title, false)
}
// openExamWebViewOpts mở nội dung trong WebView.
// forceWrap=true luôn dùng khung exam-view (có nút F5 / xóa cache) — dùng cho trắc nghiệm.
func (a *App) openExamWebViewOpts(targetURL, title string, forceWrap bool) error {
targetURL = strings.TrimSpace(targetURL) targetURL = strings.TrimSpace(targetURL)
if targetURL == "" { if targetURL == "" {
return errors.New("không có nội dung để mở") return errors.New("không có nội dung để mở")
} }
if isLocalExamURL(targetURL) { if forceWrap || isLocalExamURL(targetURL) {
wrapper := fmt.Sprintf( wrapper := fmt.Sprintf(
"http://127.0.0.1:34115/exam-view?url=%s&title=%s", "http://127.0.0.1:34115/exam-view?url=%s&title=%s",
url.QueryEscape(targetURL), url.QueryEscape(targetURL),
@@ -1525,7 +1968,7 @@ func (a *App) openExamWebView(targetURL, title string) error {
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper)) runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper))
return nil return nil
} }
// Trang thi bên ngoài: mở trực tiếp trong WebView (first-party cookies → giữ phiên đăng nhập). // Trang thi bên ngoài (đề/tài nguyên): mở trực tiếp để giữ first-party cookies.
guard.SuppressFor(5 * time.Second) guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL)) runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL))
return nil return nil
@@ -1617,7 +2060,8 @@ func (a *App) OpenExamQuiz() error {
if err != nil { if err != nil {
return err return err
} }
return a.openExamWebView(viewURL, "Làm trắc nghiệm") // Luôn mở trong khung có nút F5 / Xóa cache để SV tự xử lý khi trang lỗi.
return a.openExamWebViewOpts(viewURL, "Làm trắc nghiệm", true)
} }
func (a *App) OpenExamResource(fileID uint) error { func (a *App) OpenExamResource(fileID uint) error {

View File

@@ -413,7 +413,7 @@ function renderExamPanel() {
<div class="card-title-row"> <div class="card-title-row">
<div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div> <div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div>
</div> </div>
<p class="exam-panel-desc">Đề PDF và tài nguyên xem ngay trong app. Link trắc nghiệm mở trang thi — xong thì menu <strong>Simple Care → Về trang chính</strong> (Ctrl+H).</p> <p class="exam-panel-desc">Đề PDF và tài nguyên xem trong app. Trắc nghiệm có nút <strong>F5</strong> / <strong>Xóa cache</strong> trên thanh công cụ. Xong: menu <strong>Simple Care → Về trang chính</strong> (Ctrl+H).</p>
<div class="exam-panel-actions"> <div class="exam-panel-actions">
${paperBtn} ${paperBtn}
${quizBtn} ${quizBtn}

View File

@@ -11,7 +11,7 @@ import (
var ( var (
guardOnce sync.Once guardOnce sync.Once
guardViolation func(string) guardViolation func(kind, reason string)
guardStop chan struct{} guardStop chan struct{}
suppressMu sync.Mutex suppressMu sync.Mutex
suppressViolationsUntil time.Time suppressViolationsUntil time.Time
@@ -36,8 +36,8 @@ func violationsSuppressed() bool {
return time.Now().Before(suppressViolationsUntil) return time.Now().Before(suppressViolationsUntil)
} }
// Start giám sát môi trường macOS — vi phạm thì gọi onViolation (đa màn hình). // Start giám sát môi trường macOS — vi phạm thì gọi onViolation(kind, reason).
func Start(onViolation func(reason string)) { func Start(onViolation func(kind, reason string)) {
guardOnce.Do(func() { guardOnce.Do(func() {
if onViolation == nil { if onViolation == nil {
return return
@@ -46,7 +46,7 @@ func Start(onViolation func(reason string)) {
guardStop = make(chan struct{}) guardStop = make(chan struct{})
if screenshot.NumActiveDisplays() > 1 { if screenshot.NumActiveDisplays() > 1 {
onViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.") onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
return return
} }
@@ -83,7 +83,7 @@ func checkEnvironment() {
return return
} }
if n := screenshot.NumActiveDisplays(); n > 1 { if n := screenshot.NumActiveDisplays(); n > 1 {
guardViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.") guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
return return
} }
} }

View File

@@ -11,7 +11,7 @@ import (
var ( var (
guardOnce sync.Once guardOnce sync.Once
guardViolation func(string) guardViolation func(kind, reason string)
guardStop chan struct{} guardStop chan struct{}
suppressMu sync.Mutex suppressMu sync.Mutex
suppressViolationsUntil time.Time suppressViolationsUntil time.Time
@@ -37,7 +37,7 @@ func violationsSuppressed() bool {
} }
// Start monitors the Linux desktop environment (multi-display check) // Start monitors the Linux desktop environment (multi-display check)
func Start(onViolation func(reason string)) { func Start(onViolation func(kind, reason string)) {
guardOnce.Do(func() { guardOnce.Do(func() {
if onViolation == nil { if onViolation == nil {
return return
@@ -46,7 +46,7 @@ func Start(onViolation func(reason string)) {
guardStop = make(chan struct{}) guardStop = make(chan struct{})
if screenshot.NumActiveDisplays() > 1 { if screenshot.NumActiveDisplays() > 1 {
onViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.") onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
return return
} }
@@ -84,7 +84,7 @@ func checkEnvironment() {
return return
} }
if n := screenshot.NumActiveDisplays(); n > 1 { if n := screenshot.NumActiveDisplays(); n > 1 {
guardViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.") guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
return return
} }
} }

View File

@@ -4,6 +4,6 @@ package guard
import "time" import "time"
func Start(onViolation func(reason string)) {} func Start(onViolation func(kind, reason string)) {}
func Stop() {} func Stop() {}
func SuppressFor(_ time.Duration) {} func SuppressFor(_ time.Duration) {}

View File

@@ -73,7 +73,7 @@ type msg struct {
var ( var (
guardOnce sync.Once guardOnce sync.Once
guardViolation func(string) guardViolation func(kind, reason string)
guardStop chan struct{} guardStop chan struct{}
guardBaselineUser string guardBaselineUser string
guardBaselineSession uint32 guardBaselineSession uint32
@@ -102,8 +102,8 @@ func violationsSuppressed() bool {
return time.Now().Before(suppressViolationsUntil) return time.Now().Before(suppressViolationsUntil)
} }
// Start giám sát môi trường Windows — vi phạm thì gọi onViolation (đổi user, đa màn hình, đổi desktop ảo). // Start giám sát môi trường Windows — vi phạm thì gọi onViolation(kind, reason).
func Start(onViolation func(reason string)) { func Start(onViolation func(kind, reason string)) {
guardOnce.Do(func() { guardOnce.Do(func() {
if onViolation == nil { if onViolation == nil {
return return
@@ -114,7 +114,7 @@ func Start(onViolation func(reason string)) {
guardBaselineSession = currentSessionID() guardBaselineSession = currentSessionID()
if monitorCount() > 1 { if monitorCount() > 1 {
onViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.") onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
return return
} }
@@ -147,27 +147,27 @@ func checkEnvironment() {
return return
} }
if n := monitorCount(); n > 1 { if n := monitorCount(); n > 1 {
guardViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.") guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
return return
} }
user := currentUsername() user := currentUsername()
if user != "" && guardBaselineUser != "" && user != guardBaselineUser { if user != "" && guardBaselineUser != "" && user != guardBaselineUser {
guardViolation("Phát hiện đổi tài khoản Windows. Ứng dụng sẽ thoát.") guardViolation("user_switch", "Phát hiện đổi tài khoản Windows. Ứng dụng sẽ thoát.")
return return
} }
sid := currentSessionID() sid := currentSessionID()
if sid != 0 && guardBaselineSession != 0 && sid != guardBaselineSession { if sid != 0 && guardBaselineSession != 0 && sid != guardBaselineSession {
guardViolation("Phiên đăng nhập Windows đã thay đổi. Ứng dụng sẽ thoát.") guardViolation("session_change", "Phiên đăng nhập Windows đã thay đổi. Ứng dụng sẽ thoát.")
} }
} }
func triggerViolation(reason string) { func triggerViolation(kind, reason string) {
if violationsSuppressed() { if violationsSuppressed() {
log.Printf("[GUARD] suppressed: %s", reason) log.Printf("[GUARD] suppressed: %s", reason)
return return
} }
if guardViolation != nil { if guardViolation != nil {
guardViolation(reason) guardViolation(kind, reason)
} }
} }
@@ -262,7 +262,7 @@ func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr {
case wmWtsSessionChange: case wmWtsSessionChange:
switch uint32(wParam) { switch uint32(wParam) {
case wtsSessionLock, wtsSessionLogoff, wtsConsoleDisconnect, wtsRemoteDisconnect: case wtsSessionLock, wtsSessionLogoff, wtsConsoleDisconnect, wtsRemoteDisconnect:
triggerViolation("Phiên Windows bị khóa, đăng xuất hoặc chuyển người dùng. Ứng dụng sẽ thoát.") triggerViolation("session_change", "Phiên Windows bị khóa, đăng xuất hoặc chuyển người dùng. Ứng dụng sẽ thoát.")
} }
} }
r, _, _ := procDefWindowProcW.Call(hwnd, msg, wParam, lParam) r, _, _ := procDefWindowProcW.Call(hwnd, msg, wParam, lParam)
@@ -271,7 +271,7 @@ func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr {
func desktopSwitchCallback(hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime uintptr) uintptr { func desktopSwitchCallback(hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime uintptr) uintptr {
if event == eventSystemDesktopSwitch { if event == eventSystemDesktopSwitch {
triggerViolation("Không được chuyển Desktop ảo (Win+Tab). Ứng dụng sẽ thoát.") triggerViolation("virtual_desktop", "Không được chuyển Desktop ảo (Win+Tab). Ứng dụng sẽ thoát.")
} }
return 0 return 0
} }

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"embed" "embed"
"log" "log"
"os" "os"
@@ -46,6 +47,12 @@ func main() {
fileMenu.AddText("Về trang chính", keys.CmdOrCtrl("h"), func(_ *menu.CallbackData) { fileMenu.AddText("Về trang chính", keys.CmdOrCtrl("h"), func(_ *menu.CallbackData) {
app.ReturnToDashboard() app.ReturnToDashboard()
}) })
fileMenu.AddText("Làm mới trang (F5)", keys.Key("f5"), func(_ *menu.CallbackData) {
app.ReloadExamPage()
})
fileMenu.AddText("Xóa cache trình duyệt", keys.CmdOrCtrl("Delete"), func(_ *menu.CallbackData) {
app.ClearExamBrowserCache()
})
// Create application with options // Create application with options
err := wails.Run(&options.App{ err := wails.Run(&options.App{
@@ -58,6 +65,9 @@ func main() {
}, },
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup, OnStartup: app.startup,
OnBeforeClose: func(ctx context.Context) (prevent bool) {
return app.HandleBeforeClose()
},
Windows: &windows.Options{ Windows: &windows.Options{
WebviewUserDataPath: app.webviewDataPath, WebviewUserDataPath: app.webviewDataPath,
}, },

View File

@@ -381,6 +381,31 @@ export interface StudentSessionLogItem {
lastActiveAt?: string; lastActiveAt?: string;
} }
export interface StudentViolationItem {
id: number;
studentRkId: number;
studentCode: string;
fullName: string;
classRkId: number;
kind: string;
reason: string;
monitorMode: string;
clientAt?: string;
createdAt: string;
}
export const VIOLATION_KIND_OPTIONS = [
{ value: '', label: 'Tất cả loại' },
{ value: 'app_closed', label: 'Tự đóng app' },
{ 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' },
{ value: 'session_change', label: 'Khóa / đổi phiên' },
{ value: 'virtual_desktop', label: 'Desktop ảo' },
{ value: 'wifi', label: 'WiFi trái phép' },
{ value: 'guard', label: 'Vi phạm môi trường (cũ)' },
] as const;
export const api = { export const api = {
getStats: async (): Promise<StatsResponse> => { getStats: async (): Promise<StatsResponse> => {
const res = await staffFetch('/stats'); const res = await staffFetch('/stats');
@@ -716,6 +741,30 @@ export const apiFetchClassSessionLogs = async (
return res.json(); return res.json();
}; };
export const apiFetchClassViolations = async (
rkId: number,
date: string,
kind = ''
): Promise<{ data: StudentViolationItem[]; date: string }> => {
const params = new URLSearchParams({ date });
if (kind) params.set('kind', kind);
const res = await staffFetch(`/classes/${rkId}/violations?${params}`);
if (!res.ok) throw new Error('Failed to fetch class violations');
return res.json();
};
export const apiFetchExamViolations = async (
examId: number,
date: string,
kind = ''
): Promise<{ data: StudentViolationItem[]; date: string }> => {
const params = new URLSearchParams({ date });
if (kind) params.set('kind', kind);
const res = await staffFetch(`/exam-rooms/${examId}/violations?${params}`);
if (!res.ok) throw new Error('Failed to fetch exam violations');
return res.json();
};
export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => { export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => {
const res = await staffFetch(`/classes/${rkId}/online-students`); const res = await staffFetch(`/classes/${rkId}/online-students`);
if (!res.ok) throw new Error('Failed to fetch online students list'); if (!res.ok) throw new Error('Failed to fetch online students list');

View File

@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api'; import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api';
import { type StaffChatOpenDetail } from '../chatEvents'; import { type StaffChatOpenDetail } from '../chatEvents';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { onStaffChatMessage, playChatSound } from '../hooks/useStaffChatSocket'; import { onStaffChatMessage, onStudentViolation, playChatSound, kindLabel } from '../hooks/useStaffChatSocket';
export function ChatWidget() { export function ChatWidget() {
const { staff } = useAuth(); const { staff } = useAuth();
@@ -83,6 +83,13 @@ export function ChatWidget() {
return () => { off(); }; return () => { off(); };
}, [handleIncoming]); }, [handleIncoming]);
useEffect(() => {
return onStudentViolation((v) => {
const name = v.studentName || v.studentCode || `SV #${v.studentId}`;
showToast(`${kindLabel(v.kind)}`, `${name}: ${v.reason || 'Vi phạm giám sát'}`);
});
}, []);
useEffect(() => { useEffect(() => {
const onOpen = (e: Event) => { const onOpen = (e: Event) => {
const detail = (e as CustomEvent<StaffChatOpenDetail>).detail; const detail = (e as CustomEvent<StaffChatOpenDetail>).detail;
@@ -152,7 +159,7 @@ export function ChatWidget() {
const dock = ( const dock = (
<div className="chat-dock"> <div className="chat-dock">
{toast && ( {toast && (
<div className="chat-toast" role="status"> <div className={`chat-toast ${toast.title.startsWith('⚠') ? 'chat-toast-alert' : ''}`} role="status">
<strong>{toast.title}</strong> <strong>{toast.title}</strong>
<span>{toast.body}</span> <span>{toast.body}</span>
</div> </div>

View File

@@ -23,6 +23,8 @@ import { AttendancePanel } from './AttendancePanel';
import { StudentDetailModal } from './StudentDetailModal'; import { StudentDetailModal } from './StudentDetailModal';
import { ExamGridProctor } from './ExamGridProctor'; import { ExamGridProctor } from './ExamGridProctor';
import { AppPoolModal } from './AppPoolModal'; import { AppPoolModal } from './AppPoolModal';
import { onStudentPresence } from '../hooks/useStaffChatSocket';
import { ViolationsPanel } from './ViolationsPanel';
import { AppTemplatePickerModal } from './AppTemplatePickerModal'; import { AppTemplatePickerModal } from './AppTemplatePickerModal';
import { mergeKeywordCSV } from '../utils/appKeywords'; import { mergeKeywordCSV } from '../utils/appKeywords';
import { WorkspaceSeatingChart } from './WorkspaceSeatingChart'; import { WorkspaceSeatingChart } from './WorkspaceSeatingChart';
@@ -52,7 +54,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [logsLoading, setLogsLoading] = useState(false); const [logsLoading, setLogsLoading] = useState(false);
const [savingApps, setSavingApps] = useState(false); const [savingApps, setSavingApps] = useState(false);
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'grid' | 'logs' | 'attendance'>('roster'); const [activeSubTab, setActiveSubTab] = useState<'roster' | 'grid' | 'logs' | 'attendance' | 'violations'>('roster');
const [configOpen, setConfigOpen] = useState(false); const [configOpen, setConfigOpen] = useState(false);
const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule'); const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
const [appPoolOpen, setAppPoolOpen] = useState(false); const [appPoolOpen, setAppPoolOpen] = useState(false);
@@ -144,10 +146,21 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
const interval = setInterval(() => { const interval = setInterval(() => {
fetchOnlineStatus(); fetchOnlineStatus();
fetchLogs(logDate, logPeriod); fetchLogs(logDate, logPeriod);
}, 5000); }, 8000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [classId, logDate, logPeriod]); }, [classId, logDate, logPeriod]);
useEffect(() => {
return onStudentPresence(({ studentId, online }) => {
setOnlineIds((prev) => {
const has = prev.includes(studentId);
if (online && !has) return [...prev, studentId];
if (!online && has) return prev.filter((id) => id !== studentId);
return prev;
});
});
}, []);
useEffect(() => { useEffect(() => {
loadLogShifts(logDate); loadLogShifts(logDate);
if (activeSubTab === 'logs') { if (activeSubTab === 'logs') {
@@ -400,6 +413,12 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
> >
Nhật theo ca Nhật theo ca
</button> </button>
<button
className={`tab-sub-btn ${activeSubTab === 'violations' ? 'active' : ''}`}
onClick={() => setActiveSubTab('violations')}
>
Vi phạm
</button>
<button <button
className={`tab-sub-btn ${activeSubTab === 'attendance' ? 'active' : ''}`} className={`tab-sub-btn ${activeSubTab === 'attendance' ? 'active' : ''}`}
onClick={() => setActiveSubTab('attendance')} onClick={() => setActiveSubTab('attendance')}
@@ -410,7 +429,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
</div> </div>
{/* Student Search */} {/* Student Search */}
{activeSubTab !== 'attendance' && activeSubTab !== 'grid' && <div className="search-input-wrapper"> {activeSubTab !== 'attendance' && activeSubTab !== 'grid' && activeSubTab !== 'violations' && <div className="search-input-wrapper">
<input <input
type="text" type="text"
className="search-input" className="search-input"
@@ -425,9 +444,11 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }}></div> <div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }}></div>
</div> </div>
<div className={`workspace-panel-body ${activeSubTab === 'attendance' || activeSubTab === 'logs' || activeSubTab === 'grid' || activeSubTab === 'roster' ? 'workspace-panel-fill' : ''}`}> <div className={`workspace-panel-body ${activeSubTab === 'attendance' || activeSubTab === 'logs' || activeSubTab === 'grid' || activeSubTab === 'roster' || activeSubTab === 'violations' ? 'workspace-panel-fill' : ''}`}>
{activeSubTab === 'attendance' ? ( {activeSubTab === 'attendance' ? (
<AttendancePanel classId={classId} /> <AttendancePanel classId={classId} />
) : activeSubTab === 'violations' ? (
<ViolationsPanel mode="class" classId={classId} />
) : activeSubTab === 'grid' ? ( ) : activeSubTab === 'grid' ? (
<ExamGridProctor <ExamGridProctor
students={students.map((s) => ({ students={students.map((s) => ({

View File

@@ -22,34 +22,72 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
const [isFullscreen, setIsFullscreen] = useState<boolean>(false); const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const subscribedRef = useRef<Set<number>>(new Set());
const gridContainerRef = useRef<HTMLDivElement>(null); const gridContainerRef = useRef<HTMLDivElement>(null);
const studentIdsString = useMemo( const studentIdsString = useMemo(
() => students.map((s) => s.studentRkId).join(','), () => students.map((s) => s.studentRkId).join(','),
[students] [students]
); );
const onlineKey = useMemo(() => onlineIds.join(','), [onlineIds]);
const syncSubscriptions = (ws: WebSocket) => {
if (ws.readyState !== WebSocket.OPEN) return;
const target = new Set(onlineIds);
const prev = subscribedRef.current;
prev.forEach((id) => {
if (!target.has(id)) {
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } }));
prev.delete(id);
}
});
onlineIds.forEach((id) => {
if (!prev.has(id)) {
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id } }));
prev.add(id);
}
});
};
useEffect(() => { useEffect(() => {
if (students.length === 0) return; if (students.length === 0) return;
const wsUrl = getWsUrl('/ws?role=teacher'); 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); const ws = new WebSocket(wsUrl);
wsRef.current = ws; wsRef.current = ws;
subscribedRef.current = new Set();
ws.onopen = () => { ws.onopen = () => {
students.forEach((s) => { attempt = 0;
ws.send( syncSubscriptions(ws);
JSON.stringify({ clearPing();
event: 'teacher:subscribe', pingTimer = setInterval(() => {
data: { studentId: s.studentRkId }, if (ws.readyState === WebSocket.OPEN) {
}) ws.send(JSON.stringify({ event: 'client:ping', data: {} }));
); }
}); }, 15000);
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.event === 'client:pong') return;
if (msg.event === 'teacher:screenshot-stream-frame') { if (msg.event === 'teacher:screenshot-stream-frame') {
const { studentId, imageBuffer } = msg.data; const { studentId, imageBuffer } = msg.data;
setScreenFrames((prev) => ({ ...prev, [studentId]: imageBuffer })); setScreenFrames((prev) => ({ ...prev, [studentId]: imageBuffer }));
@@ -74,21 +112,39 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
} }
}; };
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 () => { return () => {
if (ws.readyState === WebSocket.OPEN) { closed = true;
students.forEach((s) => { clearPing();
ws.send( if (retryTimer) clearTimeout(retryTimer);
JSON.stringify({ const ws = wsRef.current;
event: 'teacher:unsubscribe', if (ws?.readyState === WebSocket.OPEN) {
data: { studentId: s.studentRkId }, subscribedRef.current.forEach((id) => {
}) ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } }));
);
}); });
} }
ws.close(); ws?.close();
wsRef.current = null;
subscribedRef.current = new Set();
}; };
}, [studentIdsString]); }, [studentIdsString]);
useEffect(() => {
const ws = wsRef.current;
if (ws) syncSubscriptions(ws);
}, [onlineKey]);
// Clean up frames when students are removed or go offline // Clean up frames when students are removed or go offline
useEffect(() => { useEffect(() => {
setScreenFrames((prev) => { setScreenFrames((prev) => {

View File

@@ -20,6 +20,8 @@ import { StudentDetailModal } from './StudentDetailModal';
import { fmtTime, localInputToISO, toLocalInput } from './ExamsTab'; import { fmtTime, localInputToISO, toLocalInput } from './ExamsTab';
import { ExamGridProctor } from './ExamGridProctor'; import { ExamGridProctor } from './ExamGridProctor';
import { WorkspaceSeatingChart } from './WorkspaceSeatingChart'; import { WorkspaceSeatingChart } from './WorkspaceSeatingChart';
import { onStudentPresence } from '../hooks/useStaffChatSocket';
import { ViolationsPanel } from './ViolationsPanel';
const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])]; const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])];
@@ -88,7 +90,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [onlineIds, setOnlineIds] = useState<number[]>([]); const [onlineIds, setOnlineIds] = useState<number[]>([]);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedStudent, setSelectedStudent] = useState<StudentItem | null>(null); const [selectedStudent, setSelectedStudent] = useState<StudentItem | null>(null);
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions' | 'grid'>('roster'); const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions' | 'grid' | 'violations'>('roster');
const [configOpen, setConfigOpen] = useState(false); const [configOpen, setConfigOpen] = useState(false);
const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info'); const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info');
const [papersModalOpen, setPapersModalOpen] = useState(false); const [papersModalOpen, setPapersModalOpen] = useState(false);
@@ -163,10 +165,23 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
useEffect(() => { useEffect(() => {
fetchOnline(); fetchOnline();
const t = setInterval(fetchOnline, 5000); const t = setInterval(fetchOnline, 8000);
return () => clearInterval(t); return () => clearInterval(t);
}, [fetchOnline]); }, [fetchOnline]);
useEffect(() => {
const roster = new Set(students.map((s) => s.studentRkId));
return onStudentPresence(({ studentId, online }) => {
if (!roster.has(studentId)) return;
setOnlineIds((prev) => {
const has = prev.includes(studentId);
if (online && !has) return [...prev, studentId];
if (!online && has) return prev.filter((id) => id !== studentId);
return prev;
});
});
}, [students]);
useEffect(() => { useEffect(() => {
if (!studentPickerOpen || !searchQ.trim()) { if (!studentPickerOpen || !searchQ.trim()) {
if (!studentPickerOpen) setSearchHits([]); if (!studentPickerOpen) setSearchHits([]);
@@ -448,6 +463,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
}; };
const handlePublish = async () => { const handlePublish = async () => {
if (!quizUrl.trim()) {
const ok = confirm(
'Phòng thi chưa có link trắc nghiệm.\n\nBạn có chắc chắn muốn đẩy phòng thi không có phần thi trắc nghiệm không?'
);
if (!ok) return;
}
setErr(''); setMsg(''); setErr(''); setMsg('');
try { try {
await apiExam.publish(examId); await apiExam.publish(examId);
@@ -880,11 +901,18 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
> >
Bài nộp ({submissions.length}) Bài nộp ({submissions.length})
</button> </button>
<button
type="button"
className={`tab-sub-btn ${activeSubTab === 'violations' ? 'active' : ''}`}
onClick={() => setActiveSubTab('violations')}
>
Vi phạm
</button>
</div> </div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{activeSubTab !== 'submissions' && activeSubTab !== 'grid' && ( {activeSubTab !== 'submissions' && activeSubTab !== 'grid' && activeSubTab !== 'violations' && (
<div className="search-input-wrapper"> <div className="search-input-wrapper">
<input <input
type="text" type="text"
@@ -1014,6 +1042,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
)} )}
</div> </div>
</div> </div>
) : activeSubTab === 'violations' ? (
<ViolationsPanel mode="exam" examId={examId} />
) : ( ) : (
<div className="session-logs-panel"> <div className="session-logs-panel">
{submissions.length > 0 && ( {submissions.length > 0 && (

View File

@@ -32,6 +32,9 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
useEffect(() => { useEffect(() => {
const wsUrl = getWsUrl('/ws?role=teacher'); 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; intentionalClose.current = false;
hasOpened.current = false; hasOpened.current = false;
@@ -41,9 +44,6 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
setScreenFrame(null); setScreenFrame(null);
setWebcamFrame(null); setWebcamFrame(null);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
const markStreaming = () => { const markStreaming = () => {
if (!hasFrames.current) { if (!hasFrames.current) {
hasFrames.current = true; hasFrames.current = true;
@@ -52,15 +52,37 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
} }
}; };
const clearPing = () => {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
};
const connect = () => {
if (intentionalClose.current) return;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
attempt = 0;
hasOpened.current = true; hasOpened.current = true;
setStreaming(true); setStreaming(true);
setErrorMessage(null);
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } })); ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } }));
clearPing();
pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ event: 'client:ping', data: {} }));
}
}, 15000);
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.event === 'client:pong') return;
if (msg.event === 'teacher:screenshot-stream-frame' && msg.data.studentId === studentId) { if (msg.event === 'teacher:screenshot-stream-frame' && msg.data.studentId === studentId) {
setScreenFrame(msg.data.imageBuffer); setScreenFrame(msg.data.imageBuffer);
markStreaming(); markStreaming();
@@ -81,19 +103,30 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
ws.onerror = () => {}; ws.onerror = () => {};
ws.onclose = () => { ws.onclose = () => {
clearPing();
if (intentionalClose.current) return; if (intentionalClose.current) return;
setStreaming(false); setStreaming(false);
if (!hasOpened.current && !hasFrames.current) { if (!hasOpened.current && !hasFrames.current) {
setErrorMessage('Không thể kết nối máy chủ giám sát'); 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 () => { return () => {
intentionalClose.current = true; intentionalClose.current = true;
if (ws.readyState === WebSocket.OPEN) { clearPing();
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } })); if (retryTimer) clearTimeout(retryTimer);
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } }));
} }
ws.close(); wsRef.current?.close();
wsRef.current = null;
}; };
}, [studentId]); }, [studentId]);

View File

@@ -0,0 +1,127 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
apiFetchClassViolations,
apiFetchExamViolations,
VIOLATION_KIND_OPTIONS,
type StudentViolationItem,
} from '../api';
import { kindLabel } from '../hooks/useStaffChatSocket';
type Props =
| { mode: 'class'; classId: number }
| { mode: 'exam'; examId: number };
function todayLocal(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function formatTime(iso?: string): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString('vi-VN');
}
export const ViolationsPanel: React.FC<Props> = (props) => {
const [date, setDate] = useState(todayLocal);
const [kind, setKind] = useState('');
const [rows, setRows] = useState<StudentViolationItem[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
const load = useCallback(async () => {
setLoading(true);
setErr('');
try {
const res =
props.mode === 'class'
? await apiFetchClassViolations(props.classId, date, kind)
: await apiFetchExamViolations(props.examId, date, kind);
setRows(res.data || []);
} catch (e: any) {
setErr(e?.message || 'Không tải được danh sách vi phạm');
setRows([]);
} finally {
setLoading(false);
}
}, [props, date, kind]);
useEffect(() => {
void load();
}, [load]);
return (
<div className="session-logs-panel" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', height: '100%' }}>
<div className="attendance-toolbar" style={{ marginBottom: 0, flexWrap: 'wrap' }}>
<label className="attendance-field">
<span>Ngày</span>
<input type="date" className="search-input" style={{ padding: '0.5rem 0.75rem' }} value={date} onChange={(e) => setDate(e.target.value)} />
</label>
<label className="attendance-field">
<span>Loại</span>
<select className="select-filter" value={kind} onChange={(e) => setKind(e.target.value)}>
{VIOLATION_KIND_OPTIONS.map((o) => (
<option key={o.value || 'all'} value={o.value}>{o.label}</option>
))}
</select>
</label>
<button type="button" className="btn btn-secondary btn-sm" onClick={() => void load()} disabled={loading}>
{loading ? 'Đang tải...' : 'Làm mới'}
</button>
<span style={{ marginLeft: 'auto', fontSize: '0.82rem', color: 'var(--text-muted)', fontWeight: 600 }}>
{rows.length} vi phạm
</span>
</div>
{err && <div className="form-error" style={{ margin: 0 }}>{err}</div>}
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none', flex: 1, overflowY: 'auto' }}>
{loading && rows.length === 0 ? (
<div className="empty-state" style={{ minHeight: '220px' }}>
<div className="sync-spinner" style={{ width: '32px', height: '32px' }} />
<p style={{ marginTop: '0.5rem' }}>Đang tải vi phạm...</p>
</div>
) : rows.length === 0 ? (
<div className="empty-state" style={{ minHeight: '220px' }}>
<p>Không vi phạm trong ngày đã chọn.</p>
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th>Thời gian</th>
<th>Sinh viên</th>
<th> SV</th>
<th>Loại</th>
<th>Chi tiết</th>
<th>Chế đ</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td style={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.8rem' }}>
{formatTime(r.createdAt || r.clientAt)}
</td>
<td style={{ fontWeight: 600 }}>{r.fullName || '—'}</td>
<td><code>{r.studentCode || r.studentRkId}</code></td>
<td>
<span className="badge badge-warning" style={{ fontSize: '0.72rem' }}>
{kindLabel(r.kind)}
</span>
</td>
<td style={{ maxWidth: 360, fontSize: '0.85rem' }} title={r.reason}>
{r.reason}
</td>
<td style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{r.monitorMode || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};

View File

@@ -1,18 +1,59 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { type ChatMessage, getWsUrl } from '../api'; import { type ChatMessage, getWsUrl } from '../api';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { playChatSound } from '../utils/notifySound'; import { playChatSound, playAlertSound } from '../utils/notifySound';
type ChatIncomingHandler = (msg: ChatMessage) => void; type ChatIncomingHandler = (msg: ChatMessage) => void;
export type PresenceUpdate = { studentId: number; online: boolean; classId?: number };
export type StudentViolationEvent = {
studentId: number;
studentName?: string;
studentCode?: string;
kind: string;
reason: string;
monitorMode?: string;
classId?: number;
};
const handlers = new Set<ChatIncomingHandler>(); const chatHandlers = new Set<ChatIncomingHandler>();
const presenceHandlers = new Set<(p: PresenceUpdate) => void>();
const violationHandlers = new Set<(v: StudentViolationEvent) => void>();
export function onStaffChatMessage(handler: ChatIncomingHandler): () => void { export function onStaffChatMessage(handler: ChatIncomingHandler): () => void {
handlers.add(handler); chatHandlers.add(handler);
return () => { handlers.delete(handler); }; return () => { chatHandlers.delete(handler); };
} }
/** WebSocket luôn bật khi đã login — nhận tin sinh viên realtime */ export function onStudentPresence(handler: (p: PresenceUpdate) => void): () => void {
presenceHandlers.add(handler);
return () => { presenceHandlers.delete(handler); };
}
export function onStudentViolation(handler: (v: StudentViolationEvent) => void): () => void {
violationHandlers.add(handler);
return () => { violationHandlers.delete(handler); };
}
function scheduleBackoff(attempt: number): number {
const base = Math.min(1000 * 2 ** Math.min(attempt, 4), 10000);
return base + Math.floor(Math.random() * 250);
}
export function kindLabel(kind: string): string {
switch (kind) {
case 'app_closed': return 'Tự đóng app';
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';
case 'session_change': return 'Khóa / đổi phiên';
case 'virtual_desktop': return 'Desktop ảo';
case 'wifi': return 'WiFi trái phép';
case 'guard': return 'Vi phạm môi trường';
default: return kind || 'Vi phạm';
}
}
/** WebSocket luôn bật khi đã login — chat + presence + violation + keepalive. */
export function StaffChatSocket() { export function StaffChatSocket() {
const { staff, token } = useAuth(); const { staff, token } = useAuth();
const staffIdRef = useRef(0); const staffIdRef = useRef(0);
@@ -21,8 +62,8 @@ export function StaffChatSocket() {
staffIdRef.current = staff?.id ?? 0; staffIdRef.current = staff?.id ?? 0;
}, [staff?.id]); }, [staff?.id]);
const dispatch = useCallback((msg: ChatMessage) => { const dispatchChat = useCallback((msg: ChatMessage) => {
handlers.forEach((h) => h(msg)); chatHandlers.forEach((h) => h(msg));
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -31,26 +72,74 @@ export function StaffChatSocket() {
const wsUrl = getWsUrl(`/ws?role=teacher&staffId=${staff.id}`); const wsUrl = getWsUrl(`/ws?role=teacher&staffId=${staff.id}`);
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null; let retryTimer: ReturnType<typeof setTimeout> | null = null;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let closed = false; let closed = false;
let attempt = 0;
const clearPing = () => {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
};
const connect = () => { const connect = () => {
if (closed) return; if (closed) return;
ws = new WebSocket(wsUrl); ws = new WebSocket(wsUrl);
ws.onopen = () => {
attempt = 0;
clearPing();
pingTimer = setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ event: 'client:ping', data: {} }));
}
}, 15000);
};
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
try { try {
const payload = JSON.parse(ev.data); const payload = JSON.parse(ev.data);
if (payload.event === 'client:pong') return;
if (payload.event === 'presence:update') {
const studentId = Number(payload.data?.studentId ?? 0);
if (!studentId) return;
presenceHandlers.forEach((h) => h({
studentId,
online: !!payload.data?.online,
classId: Number(payload.data?.classId ?? 0) || undefined,
}));
return;
}
if (payload.event === 'teacher:student-violation') {
const studentId = Number(payload.data?.studentId ?? 0);
if (!studentId) return;
playAlertSound();
violationHandlers.forEach((h) => h({
studentId,
studentName: payload.data?.studentName || '',
studentCode: payload.data?.studentCode || '',
kind: String(payload.data?.kind || ''),
reason: String(payload.data?.reason || ''),
monitorMode: payload.data?.monitorMode || '',
classId: Number(payload.data?.classId ?? 0) || undefined,
}));
return;
}
if (payload.event !== 'chat:message') return; if (payload.event !== 'chat:message') return;
const msg = payload.data as ChatMessage; const msg = payload.data as ChatMessage;
const targetStaff = Number(msg.targetStaffId ?? msg.staffId ?? 0); const targetStaff = Number(msg.targetStaffId ?? msg.staffId ?? 0);
if (targetStaff > 0 && targetStaff !== staffIdRef.current) return; if (targetStaff > 0 && targetStaff !== staffIdRef.current) return;
dispatch(msg); dispatchChat(msg);
} catch { } catch {
/* ignore */ /* ignore */
} }
}; };
ws.onclose = () => { ws.onclose = () => {
clearPing();
if (!closed) { if (!closed) {
retryTimer = setTimeout(connect, 3000); retryTimer = setTimeout(connect, scheduleBackoff(attempt++));
} }
}; };
}; };
@@ -59,12 +148,13 @@ export function StaffChatSocket() {
return () => { return () => {
closed = true; closed = true;
clearPing();
if (retryTimer) clearTimeout(retryTimer); if (retryTimer) clearTimeout(retryTimer);
ws?.close(); ws?.close();
}; };
}, [token, staff?.id, dispatch]); }, [token, staff?.id, dispatchChat]);
return null; return null;
} }
export { playChatSound }; export { playChatSound, playAlertSound };

View File

@@ -3874,6 +3874,15 @@ input:checked + .slider:before {
color: #93c5fd; color: #93c5fd;
} }
.chat-toast-alert {
background: #7f1d1d;
border: 1px solid #f87171;
}
.chat-toast-alert strong {
color: #fecaca;
}
@keyframes chat-toast-in { @keyframes chat-toast-in {
from { opacity: 0; transform: translateY(8px); } from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }

View File

@@ -35,3 +35,29 @@ export function playChatSound() {
playTone(880, t, 0.12); playTone(880, t, 0.12);
playTone(1174, t + 0.14, 0.14); playTone(1174, t + 0.14, 0.14);
} }
/** Louder alert for student violations (quit / kill app) */
export function playAlertSound() {
const ctx = getAudioCtx();
if (!ctx) return;
if (ctx.state === 'suspended') {
void ctx.resume();
}
const playTone = (freq: number, start: number, duration: number) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.1, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(start);
osc.stop(start + duration + 0.02);
};
const t = ctx.currentTime;
playTone(520, t, 0.16);
playTone(390, t + 0.18, 0.2);
playTone(520, t + 0.4, 0.18);
}

View File

@@ -53,6 +53,7 @@ func AutoMigrate(db *gorm.DB) error {
&models.WifiPoolEntry{}, &models.WifiPoolEntry{},
&models.AcceptedWifi{}, &models.AcceptedWifi{},
&models.StudentSession{}, &models.StudentSession{},
&models.StudentViolation{},
&models.AttendanceResult{}, &models.AttendanceResult{},
&models.StaffAccount{}, &models.StaffAccount{},
&models.EmailDomain{}, &models.EmailDomain{},

View File

@@ -735,6 +735,227 @@ func ReportBlockedAppHandler(db *gorm.DB) fiber.Handler {
} }
} }
// POST /api/student/report-violation — SV tắt app / vi phạm khi đang giám sát
func ReportViolationHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
StudentRkID int64 `json:"studentRkId"`
Kind string `json:"kind"`
Reason string `json:"reason"`
MonitorMode string `json:"monitorMode"`
ClientAt string `json:"clientAt"` // RFC3339 optional
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
}
if req.StudentRkID <= 0 || strings.TrimSpace(req.Kind) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId and kind are required"})
}
kind := strings.ToLower(strings.TrimSpace(req.Kind))
reason := strings.TrimSpace(req.Reason)
if reason == "" {
reason = kind
}
classID := internalDb.FindActiveClassForStudent(db, req.StudentRkID)
clientAt := time.Now()
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.ClientAt)); err == nil {
clientAt = t
}
row := models.StudentViolation{
StudentRkID: req.StudentRkID,
ClassRkID: classID,
Kind: kind,
Reason: reason,
MonitorMode: strings.TrimSpace(req.MonitorMode),
ClientAt: clientAt,
}
if err := db.Create(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
studentName := ""
studentCode := ""
var st models.Student
if err := db.Where("rk_id = ?", req.StudentRkID).First(&st).Error; err == nil {
studentName = st.FullName
studentCode = st.StudentCode
}
// Cố ý tắt / crash / môi trường → offline ngay, không dùng grace 8s
switch kind {
case "app_closed", "unclean_shutdown", "wifi",
"guard", "multi_monitor", "user_switch", "session_change", "virtual_desktop":
internalWs.Hub.ForceStudentOffline(req.StudentRkID)
}
internalWs.Hub.BroadcastStudentViolation(map[string]any{
"id": row.ID,
"studentId": req.StudentRkID,
"studentName": studentName,
"studentCode": studentCode,
"classId": classID,
"kind": kind,
"reason": reason,
"monitorMode": row.MonitorMode,
"clientAt": clientAt.Format(time.RFC3339),
"createdAt": row.CreatedAt.Format(time.RFC3339),
})
return c.JSON(fiber.Map{"ok": true, "id": row.ID})
}
}
type studentViolationItem struct {
ID uint `json:"id"`
StudentRkID int64 `json:"studentRkId"`
StudentCode string `json:"studentCode"`
FullName string `json:"fullName"`
ClassRkID int64 `json:"classRkId"`
Kind string `json:"kind"`
Reason string `json:"reason"`
MonitorMode string `json:"monitorMode"`
ClientAt time.Time `json:"clientAt"`
CreatedAt time.Time `json:"createdAt"`
}
// GET /api/classes/:rkId/violations?date=YYYY-MM-DD&kind=
func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
date := c.Query("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
kindFilter := strings.TrimSpace(c.Query("kind", ""))
var mappings []models.ClassStudent
if err := db.Where("class_rk_id = ?", classRkID).Find(&mappings).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if len(mappings) == 0 {
return c.JSON(fiber.Map{"data": []any{}})
}
studentIDs := make([]int64, 0, len(mappings))
for _, m := range mappings {
studentIDs = append(studentIDs, m.StudentRkID)
}
var students []models.Student
if err := db.Where("rk_id IN ?", studentIDs).Find(&students).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
nameByID := map[int64]models.Student{}
for _, st := range students {
nameByID[st.RkID] = st
}
dayStart, err := time.ParseInLocation("2006-01-02", date, time.Local)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"})
}
dayEnd := dayStart.Add(24 * time.Hour)
q := db.Where("student_rk_id IN ? AND created_at >= ? AND created_at < ?", studentIDs, dayStart, dayEnd)
if kindFilter != "" {
q = q.Where("kind = ?", kindFilter)
}
var rows []models.StudentViolation
if err := q.Order("created_at desc").Limit(500).Find(&rows).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
out := make([]studentViolationItem, 0, len(rows))
for _, r := range rows {
st := nameByID[r.StudentRkID]
out = append(out, studentViolationItem{
ID: r.ID,
StudentRkID: r.StudentRkID,
StudentCode: st.StudentCode,
FullName: st.FullName,
ClassRkID: r.ClassRkID,
Kind: r.Kind,
Reason: r.Reason,
MonitorMode: r.MonitorMode,
ClientAt: r.ClientAt,
CreatedAt: r.CreatedAt,
})
}
return c.JSON(fiber.Map{"data": out, "date": date})
}
}
// GET /api/exam-rooms/:id/violations?date=YYYY-MM-DD
func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid exam room id"})
}
date := c.Query("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
kindFilter := strings.TrimSpace(c.Query("kind", ""))
var roster []models.ExamRoomStudent
if err := db.Where("exam_room_id = ?", id).Find(&roster).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if len(roster) == 0 {
return c.JSON(fiber.Map{"data": []any{}})
}
studentIDs := make([]int64, 0, len(roster))
for _, s := range roster {
studentIDs = append(studentIDs, s.StudentRkID)
}
var students []models.Student
_ = db.Where("rk_id IN ?", studentIDs).Find(&students)
nameByID := map[int64]models.Student{}
for _, st := range students {
nameByID[st.RkID] = st
}
dayStart, err := time.ParseInLocation("2006-01-02", date, time.Local)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"})
}
dayEnd := dayStart.Add(24 * time.Hour)
q := db.Where("student_rk_id IN ? AND created_at >= ? AND created_at < ?", studentIDs, dayStart, dayEnd)
if kindFilter != "" {
q = q.Where("kind = ?", kindFilter)
}
var rows []models.StudentViolation
if err := q.Order("created_at desc").Limit(500).Find(&rows).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
out := make([]studentViolationItem, 0, len(rows))
for _, r := range rows {
st := nameByID[r.StudentRkID]
out = append(out, studentViolationItem{
ID: r.ID,
StudentRkID: r.StudentRkID,
StudentCode: st.StudentCode,
FullName: st.FullName,
ClassRkID: r.ClassRkID,
Kind: r.Kind,
Reason: r.Reason,
MonitorMode: r.MonitorMode,
ClientAt: r.ClientAt,
CreatedAt: r.CreatedAt,
})
}
return c.JSON(fiber.Map{"data": out, "date": date})
}
}
// GET /api/app-pool — kho app bị chặn toàn hệ thống (?q= tìm kiếm, limit= mặc định 50) // GET /api/app-pool — kho app bị chặn toàn hệ thống (?q= tìm kiếm, limit= mặc định 50)
func ListAppPoolHandler(db *gorm.DB) fiber.Handler { func ListAppPoolHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {

View File

@@ -179,6 +179,20 @@ type StudentSession struct {
func (StudentSession) TableName() string { return "student_sessions" } func (StudentSession) TableName() string { return "student_sessions" }
// StudentViolation — ghi nhận SV tắt app / vi phạm môi trường khi đang giám sát
type StudentViolation struct {
ID uint `gorm:"primaryKey" json:"id"`
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"`
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"`
ClientAt time.Time `gorm:"column:client_at" json:"clientAt"`
}
func (StudentViolation) TableName() string { return "student_violations" }
// SystemSetting lưu các cấu hình hệ thống động // SystemSetting lưu các cấu hình hệ thống động
type SystemSetting struct { type SystemSetting struct {
SettingKey string `gorm:"primaryKey;column:setting_key;size:128" json:"settingKey"` SettingKey string `gorm:"primaryKey;column:setting_key;size:128" json:"settingKey"`

View File

@@ -5,12 +5,21 @@ import (
"log" "log"
"strconv" "strconv"
"sync" "sync"
"time"
"github.com/gofiber/websocket/v2" "github.com/gofiber/websocket/v2"
"gorm.io/gorm" "gorm.io/gorm"
internalDb "server/internal/db" internalDb "server/internal/db"
) )
const (
// Heartbeat kiểu game: phát hiện mất kết nối trong ~3045s.
wsPingInterval = 15 * time.Second
wsPongWait = 45 * time.Second
// Grace khi SV reconnect — tránh nhấp nháy online/offline.
studentOfflineGrace = 8 * time.Second
)
type SocketMsg struct { type SocketMsg struct {
Event string `json:"event"` Event string `json:"event"`
Data map[string]any `json:"data"` Data map[string]any `json:"data"`
@@ -32,6 +41,16 @@ func (c *SocketClient) WriteJSON(v any) error {
return c.Conn.WriteJSON(v) return c.Conn.WriteJSON(v)
} }
func (c *SocketClient) WriteRaw(msg []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
return c.Conn.WriteMessage(websocket.TextMessage, msg)
}
type offlineGrace struct {
until time.Time
classID int64
}
type WsHub struct { type WsHub struct {
mu sync.RWMutex mu sync.RWMutex
@@ -39,6 +58,8 @@ type WsHub struct {
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
graceTimers map[int64]*time.Timer
} }
var Hub = &WsHub{ var Hub = &WsHub{
@@ -46,13 +67,18 @@ var Hub = &WsHub{
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),
graceTimers: make(map[int64]*time.Timer),
} }
func (h *WsHub) IsStudentOnline(studentRkID int64) bool { func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
_, ok := h.students[studentRkID] if _, ok := h.students[studentRkID]; ok {
return ok return true
}
g, ok := h.grace[studentRkID]
return ok && time.Now().Before(g.until)
} }
func (h *WsHub) PushExamToStudent(studentRkID int64, data map[string]any) { func (h *WsHub) PushExamToStudent(studentRkID int64, data map[string]any) {
@@ -90,16 +116,126 @@ func (h *WsHub) PushChatToStaff(staffID uint, data map[string]any) {
} }
} }
func (h *WsHub) BroadcastStudentViolation(data map[string]any) {
msg := SocketMsg{Event: "teacher:student-violation", Data: data}
msgBytes, err := json.Marshal(msg)
if err != nil {
return
}
h.mu.RLock()
defer h.mu.RUnlock()
for _, t := range h.teachers {
if t != nil {
_ = t.WriteRaw(msgBytes)
}
}
}
func (h *WsHub) broadcastPresence(studentID int64, online bool, classID int64) {
msg := SocketMsg{
Event: "presence:update",
Data: map[string]any{
"studentId": studentID,
"online": online,
"classId": classID,
},
}
msgBytes, err := json.Marshal(msg)
if err != nil {
return
}
for _, t := range h.teachers {
if t != nil {
_ = t.WriteRaw(msgBytes)
}
}
}
// ForceStudentOffline — tắt ngay (không chờ grace), dùng khi SV cố ý đóng app / vi phạm.
func (h *WsHub) ForceStudentOffline(studentID int64) {
h.mu.Lock()
defer h.mu.Unlock()
classID := int64(0)
if client, ok := h.students[studentID]; ok && client != nil {
classID = client.ClassID
delete(h.students, studentID)
}
if g, ok := h.grace[studentID]; ok {
if classID == 0 {
classID = g.classID
}
}
h.cancelGraceLocked(studentID)
log.Printf("[WS] Student %d force offline (intentional quit/violation)", studentID)
h.broadcastPresence(studentID, false, classID)
if teachers, exists := h.subscribers[studentID]; exists {
for _, tAddr := range teachers {
if t, found := h.teachers[tAddr]; found {
_ = t.WriteJSON(SocketMsg{
Event: "teacher:stream-stopped",
Data: map[string]any{"studentId": studentID},
})
}
}
}
}
func (h *WsHub) cancelGraceLocked(studentID int64) {
if t, ok := h.graceTimers[studentID]; ok {
t.Stop()
delete(h.graceTimers, studentID)
}
delete(h.grace, studentID)
}
func (h *WsHub) finalizeStudentOffline(studentID int64, classID int64) {
h.mu.Lock()
defer h.mu.Unlock()
if _, online := h.students[studentID]; online {
return
}
if _, ok := h.grace[studentID]; !ok {
return
}
delete(h.grace, studentID)
delete(h.graceTimers, studentID)
log.Printf("[WS] Student %d offline after grace", studentID)
h.broadcastPresence(studentID, false, classID)
if teachers, exists := h.subscribers[studentID]; exists {
for _, tAddr := range teachers {
if t, found := h.teachers[tAddr]; found {
_ = t.WriteJSON(SocketMsg{
Event: "teacher:stream-stopped",
Data: map[string]any{"studentId": studentID},
})
}
}
}
}
func (h *WsHub) GetOnlineStudentIDs(classID int64) []int64 { func (h *WsHub) GetOnlineStudentIDs(classID int64) []int64 {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
seen := map[int64]bool{}
var ids []int64 var ids []int64
now := time.Now()
for _, client := range h.students { for _, client := range h.students {
if client.ClassID == classID { if client.ClassID == classID {
seen[client.StudentID] = true
ids = append(ids, client.StudentID) ids = append(ids, client.StudentID)
} }
} }
for sid, g := range h.grace {
if now.Before(g.until) && g.classID == classID && !seen[sid] {
ids = append(ids, sid)
}
}
if ids == nil { if ids == nil {
return []int64{} return []int64{}
} }
@@ -112,8 +248,14 @@ func (h *WsHub) Register(c *SocketClient) {
c.Addr = c.Conn.RemoteAddr().String() c.Addr = c.Conn.RemoteAddr().String()
if c.Role == "student" { if c.Role == "student" {
wasInGrace := false
if _, ok := h.grace[c.StudentID]; ok {
wasInGrace = true
}
h.cancelGraceLocked(c.StudentID)
h.students[c.StudentID] = c h.students[c.StudentID] = c
log.Printf("[WS] Student %d registered (Address: %s, Class: %d)", c.StudentID, c.Addr, c.ClassID) log.Printf("[WS] Student %d registered (Address: %s, Class: %d, reconnectGrace=%v)", c.StudentID, c.Addr, c.ClassID, wasInGrace)
h.broadcastPresence(c.StudentID, true, c.ClassID)
if subs, exists := h.subscribers[c.StudentID]; exists && len(subs) > 0 { if subs, exists := h.subscribers[c.StudentID]; exists && len(subs) > 0 {
_ = c.WriteJSON(SocketMsg{Event: "start_screenshot_stream"}) _ = c.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
_ = c.WriteJSON(SocketMsg{Event: "start_webcam_stream"}) _ = c.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
@@ -133,21 +275,23 @@ func (h *WsHub) Unregister(c *SocketClient) {
defer h.mu.Unlock() defer h.mu.Unlock()
if c.Role == "student" { if c.Role == "student" {
current, exists := h.students[c.StudentID]
if !exists || current != c {
log.Printf("[WS] Student %d stale disconnect ignored (replaced by newer connection)", c.StudentID)
return
}
delete(h.students, c.StudentID) delete(h.students, c.StudentID)
log.Printf("[WS] Student %d disconnected", c.StudentID) classID := c.ClassID
studentID := c.StudentID
// Báo cho các giáo viên đang xem là stream của học sinh đã dừng h.cancelGraceLocked(studentID)
if teachers, exists := h.subscribers[c.StudentID]; exists { deadline := time.Now().Add(studentOfflineGrace)
for _, tAddr := range teachers { h.grace[studentID] = offlineGrace{until: deadline, classID: classID}
if t, found := h.teachers[tAddr]; found { h.graceTimers[studentID] = time.AfterFunc(studentOfflineGrace, func() {
_ = t.WriteJSON(SocketMsg{ h.finalizeStudentOffline(studentID, classID)
Event: "teacher:stream-stopped",
Data: map[string]any{"studentId": c.StudentID},
}) })
} log.Printf("[WS] Student %d disconnected — grace %v before offline", studentID, studentOfflineGrace)
} // Chưa broadcast offline / stream-stopped — chờ grace (reconnect nhanh như game).
delete(h.subscribers, c.StudentID)
}
} else if c.Role == "teacher" { } else if c.Role == "teacher" {
delete(h.teachers, c.Addr) delete(h.teachers, c.Addr)
if c.StaffID > 0 { if c.StaffID > 0 {
@@ -166,7 +310,6 @@ func (h *WsHub) Unregister(c *SocketClient) {
} }
log.Printf("[WS] Teacher %s disconnected", c.Addr) log.Printf("[WS] Teacher %s disconnected", c.Addr)
// Dọn dẹp subscriptions của giáo viên này
for sID, teachersList := range h.subscribers { for sID, teachersList := range h.subscribers {
newList := []string{} newList := []string{}
for _, addr := range teachersList { for _, addr := range teachersList {
@@ -176,7 +319,6 @@ func (h *WsHub) Unregister(c *SocketClient) {
} }
if len(newList) == 0 { if len(newList) == 0 {
delete(h.subscribers, sID) delete(h.subscribers, sID)
// Nếu không còn ai xem học sinh này, gửi lệnh tắt camera/screen cho client học sinh
if student, exists := h.students[sID]; exists { if student, exists := h.students[sID]; exists {
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"}) _ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"}) _ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
@@ -188,12 +330,10 @@ func (h *WsHub) Unregister(c *SocketClient) {
} }
} }
// Teacher bắt đầu xem stream của Student
func (h *WsHub) Subscribe(teacherAddr string, studentID int64) { func (h *WsHub) Subscribe(teacherAddr string, studentID int64) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
// Thêm giáo viên vào list người xem của học sinh
teachersList := h.subscribers[studentID] teachersList := h.subscribers[studentID]
alreadySubscribed := false alreadySubscribed := false
for _, addr := range teachersList { for _, addr := range teachersList {
@@ -207,14 +347,12 @@ func (h *WsHub) Subscribe(teacherAddr string, studentID int64) {
log.Printf("[WS] Teacher %s subscribed to student %d stream", teacherAddr, studentID) log.Printf("[WS] Teacher %s subscribed to student %d stream", teacherAddr, studentID)
} }
// Phát lệnh cho máy học sinh bật stream (nếu học sinh đang online)
if student, exists := h.students[studentID]; exists { if student, exists := h.students[studentID]; exists {
_ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"}) _ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
_ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"}) _ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
} }
} }
// Teacher dừng xem stream của Student
func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) { func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
@@ -234,8 +372,6 @@ func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
if len(newList) == 0 { if len(newList) == 0 {
delete(h.subscribers, studentID) delete(h.subscribers, studentID)
log.Printf("[WS] Student %d has no more proctor subscribers. Stopping streams.", studentID) log.Printf("[WS] Student %d has no more proctor subscribers. Stopping streams.", studentID)
// Báo học sinh tắt camera & screen stream để tiết kiệm mạng và CPU
if student, exists := h.students[studentID]; exists { if student, exists := h.students[studentID]; exists {
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"}) _ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"}) _ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
@@ -245,15 +381,16 @@ func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
} }
} }
// Chuyển tiếp frame ảnh từ Student đến các Teacher đã subscribe
func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) { func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock()
teachersList, exists := h.subscribers[studentID] teachersList, exists := h.subscribers[studentID]
if !exists || len(teachersList) == 0 { if !exists || len(teachersList) == 0 {
h.mu.RUnlock()
return return
} }
addrs := make([]string, len(teachersList))
copy(addrs, teachersList)
h.mu.RUnlock()
relayEvent := "teacher:screenshot-stream-frame" relayEvent := "teacher:screenshot-stream-frame"
if event == "webcam_stream_frame" { if event == "webcam_stream_frame" {
@@ -267,15 +404,35 @@ func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
"imageBuffer": data["imageBuffer"], "imageBuffer": data["imageBuffer"],
}, },
} }
msgBytes, err := json.Marshal(msg)
if err != nil {
return
}
for _, addr := range teachersList { var dead []string
h.mu.RLock()
for _, addr := range addrs {
if t, found := h.teachers[addr]; found { if t, found := h.teachers[addr]; found {
_ = t.WriteJSON(msg) if err := t.WriteRaw(msgBytes); err != nil {
log.Printf("[WS] Relay to teacher %s failed: %v", addr, err)
dead = append(dead, addr)
}
}
}
h.mu.RUnlock()
for _, addr := range dead {
if t, ok := func() (*SocketClient, bool) {
h.mu.RLock()
defer h.mu.RUnlock()
t, ok := h.teachers[addr]
return t, ok
}(); ok && t != nil {
_ = t.Conn.Close()
} }
} }
} }
// WebSocket handler cho Fiber route
func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) { func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
return func(c *websocket.Conn) { return func(c *websocket.Conn) {
role := c.Query("role", "student") role := c.Query("role", "student")
@@ -308,25 +465,51 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
c.Close() c.Close()
}() }()
c.SetReadDeadline(time.Now().Add(wsPongWait))
c.SetPongHandler(func(string) error {
return c.SetReadDeadline(time.Now().Add(wsPongWait))
})
pingDone := make(chan struct{})
defer close(pingDone)
go func() {
ticker := time.NewTicker(wsPingInterval)
defer ticker.Stop()
for {
select {
case <-pingDone:
return
case <-ticker.C:
client.writeMu.Lock()
err := c.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(5*time.Second))
client.writeMu.Unlock()
if err != nil {
return
}
}
}
}()
for { for {
_, msgBytes, err := c.ReadMessage() _, msgBytes, err := c.ReadMessage()
if err != nil { if err != nil {
break break
} }
_ = c.SetReadDeadline(time.Now().Add(wsPongWait))
var msg SocketMsg var msg SocketMsg
if err := json.Unmarshal(msgBytes, &msg); err != nil { if err := json.Unmarshal(msgBytes, &msg); err != nil {
continue continue
} }
// Xử lý các sự kiện
switch msg.Event { switch msg.Event {
case "client:ping":
_ = client.WriteJSON(SocketMsg{Event: "client:pong", Data: map[string]any{"t": time.Now().UnixMilli()}})
case "screenshot_stream_frame", "webcam_stream_frame": case "screenshot_stream_frame", "webcam_stream_frame":
// Nhận frame từ học sinh, chuyển tiếp về các thầy cô
Hub.RelayFrame(client.StudentID, msg.Event, msg.Data) Hub.RelayFrame(client.StudentID, msg.Event, msg.Data)
case "teacher:subscribe": case "teacher:subscribe":
// Giáo viên đăng ký xem học sinh cụ thể
if client.Role == "teacher" { if client.Role == "teacher" {
if sIDVal, ok := msg.Data["studentId"]; ok { if sIDVal, ok := msg.Data["studentId"]; ok {
var sID int64 var sID int64
@@ -343,7 +526,6 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
} }
case "teacher:unsubscribe": case "teacher:unsubscribe":
// Giáo viên hủy đăng ký
if client.Role == "teacher" { if client.Role == "teacher" {
if sIDVal, ok := msg.Data["studentId"]; ok { if sIDVal, ok := msg.Data["studentId"]; ok {
var sID int64 var sID int64

View File

@@ -93,6 +93,7 @@ func main() {
api.Get("/student/wifi-policy", handlers.GetStudentWifiPolicyHandler(gormDB)) api.Get("/student/wifi-policy", handlers.GetStudentWifiPolicyHandler(gormDB))
api.Post("/student/report-wifi", handlers.ReportWifiHandler(gormDB)) api.Post("/student/report-wifi", handlers.ReportWifiHandler(gormDB))
api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB)) api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB))
api.Post("/student/report-violation", handlers.ReportViolationHandler(gormDB))
api.Get("/student/chat/messages", handlers.StudentListChatHandler(gormDB)) api.Get("/student/chat/messages", handlers.StudentListChatHandler(gormDB))
api.Post("/student/chat/messages", handlers.StudentSendChatHandler(gormDB)) api.Post("/student/chat/messages", handlers.StudentSendChatHandler(gormDB))
api.Get("/student/chat/conversations", handlers.StudentListChatConversationsHandler(gormDB)) api.Get("/student/chat/conversations", handlers.StudentListChatConversationsHandler(gormDB))
@@ -156,6 +157,7 @@ func main() {
staff.Post("/network/accepted-wifis/add", handlers.AddAcceptedWifiHandler(gormDB)) staff.Post("/network/accepted-wifis/add", handlers.AddAcceptedWifiHandler(gormDB))
staff.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(gormDB)) staff.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(gormDB))
staff.Get("/classes/:rkId/session-logs", handlers.ListClassSessionLogsHandler(gormDB)) staff.Get("/classes/:rkId/session-logs", handlers.ListClassSessionLogsHandler(gormDB))
staff.Get("/classes/:rkId/violations", handlers.ListClassViolationsHandler(gormDB))
staff.Get("/classes/:rkId/online-students", handlers.GetOnlineStudentsHandler(gormDB)) staff.Get("/classes/:rkId/online-students", handlers.GetOnlineStudentsHandler(gormDB))
staff.Get("/classes/:rkId/schedule-conflicts", handlers.GetClassScheduleConflictsHandler(gormDB)) staff.Get("/classes/:rkId/schedule-conflicts", handlers.GetClassScheduleConflictsHandler(gormDB))
@@ -187,6 +189,7 @@ func main() {
staff.Get("/exam-rooms/search-students", handlers.SearchExamStudentsHandler(gormDB)) staff.Get("/exam-rooms/search-students", handlers.SearchExamStudentsHandler(gormDB))
staff.Get("/exam-rooms/:id", handlers.GetExamRoomHandler(gormDB)) staff.Get("/exam-rooms/:id", handlers.GetExamRoomHandler(gormDB))
staff.Get("/exam-rooms/:id/online-students", handlers.GetExamRoomOnlineStudentsHandler(gormDB)) staff.Get("/exam-rooms/:id/online-students", handlers.GetExamRoomOnlineStudentsHandler(gormDB))
staff.Get("/exam-rooms/:id/violations", handlers.ListExamRoomViolationsHandler(gormDB))
staff.Patch("/exam-rooms/:id", handlers.UpdateExamRoomHandler(gormDB)) staff.Patch("/exam-rooms/:id", handlers.UpdateExamRoomHandler(gormDB))
staff.Delete("/exam-rooms/:id", handlers.DeleteExamRoomHandler(gormDB)) staff.Delete("/exam-rooms/:id", handlers.DeleteExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/students", handlers.AddExamRoomStudentsHandler(gormDB)) staff.Post("/exam-rooms/:id/students", handlers.AddExamRoomStudentsHandler(gormDB))