tam 2
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m25s

This commit is contained in:
2026-07-13 09:04:09 +07:00
parent 719704f19e
commit 8803cd622a
22 changed files with 1322 additions and 121 deletions

View File

@@ -125,20 +125,25 @@ 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
wifiSSID string wsConnecting bool
wifiBSSID string wsBackoff time.Duration
allowedApps string wsWriteMu sync.Mutex
onlineSecs int wifiSSID string
offlineSecs int wifiBSSID string
unsyncedOn int allowedApps string
unsyncedOff int onlineSecs int
lastSyncTime time.Time offlineSecs int
isStreamingSc bool unsyncedOn int
streamScStop chan struct{} unsyncedOff int
isStreamingCam bool lastSyncTime time.Time
streamCamStop chan struct{} isStreamingSc bool
streamScStop chan struct{}
isStreamingCam bool
streamCamStop chan struct{}
statusMsg string statusMsg string
expectingLogin bool expectingLogin bool
needsClearPortalStorage bool needsClearPortalStorage bool
@@ -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 {
@@ -213,11 +217,13 @@ func NewApp() *App {
_ = os.MkdirAll(webviewDir, 0755) _ = os.MkdirAll(webviewDir, 0755)
return &App{ return &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,
lastSyncTime: time.Now(), pendingViolPath: filepath.Join(appDir, "pending_violations.json"),
expectingLogin: false, runLockPath: filepath.Join(appDir, "monitoring.lock"),
lastSyncTime: time.Now(),
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>`
@@ -1017,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),
@@ -1132,40 +1404,101 @@ 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 close(pingDone)
defer func() { defer func() {
a.disconnectWS(conn) a.disconnectWS(conn)
go func() { a.mu.Lock()
time.Sleep(500 * time.Millisecond) 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() { if a.isMonitoringActive() && a.CheckLoginStatus() {
a.connectWS() a.connectWS()
} }
}() })
}() }()
for { for {
var msg struct { var msg struct {
@@ -1177,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 {
@@ -1238,6 +1575,7 @@ func (a *App) disconnectWS(expectedConn ...*websocket.Conn) {
a.wsConn = nil a.wsConn = nil
} }
a.wsConnected = false a.wsConnected = false
a.wsConnecting = false
shouldStopStreams = a.isStreamingSc || a.isStreamingCam shouldStopStreams = a.isStreamingSc || a.isStreamingCam
a.mu.Unlock() a.mu.Unlock()
@@ -1523,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 == "" {
@@ -1533,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),
@@ -1547,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
@@ -1639,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

@@ -72,15 +72,15 @@ 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
desktopHook uintptr desktopHook uintptr
guardClassAtom uint16 guardClassAtom uint16
suppressMu sync.Mutex suppressMu sync.Mutex
suppressViolationsUntil time.Time suppressViolationsUntil time.Time
) )
// SuppressFor tạm không thoát app khi WebView/Explorer chuyển màn hình nội bộ. // SuppressFor tạm không thoát app khi WebView/Explorer chuyển màn hình nội bộ.
@@ -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

@@ -56,6 +56,15 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
const wsUrl = getWsUrl('/ws?role=teacher'); const wsUrl = getWsUrl('/ws?role=teacher');
let closed = false; let closed = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null; 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 = () => { const connect = () => {
if (closed) return; if (closed) return;
@@ -64,11 +73,21 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
wsRef.current = ws; wsRef.current = ws;
subscribedRef.current = new Set(); subscribedRef.current = new Set();
ws.onopen = () => syncSubscriptions(ws); 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) => { 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 }));
@@ -94,9 +113,11 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
}; };
ws.onclose = () => { ws.onclose = () => {
clearPing();
subscribedRef.current = new Set(); subscribedRef.current = new Set();
if (!closed) { if (!closed) {
retryTimer = setTimeout(connect, 3000); const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000);
retryTimer = setTimeout(connect, delay);
} }
}; };
}; };
@@ -105,6 +126,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
return () => { return () => {
closed = true; closed = true;
clearPing();
if (retryTimer) clearTimeout(retryTimer); if (retryTimer) clearTimeout(retryTimer);
const ws = wsRef.current; const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) { if (ws?.readyState === WebSocket.OPEN) {

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

@@ -33,6 +33,8 @@ 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 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;
@@ -50,6 +52,13 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
} }
}; };
const clearPing = () => {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
};
const connect = () => { const connect = () => {
if (intentionalClose.current) return; if (intentionalClose.current) return;
@@ -57,15 +66,23 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
wsRef.current = ws; wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
attempt = 0;
hasOpened.current = true; hasOpened.current = true;
setStreaming(true); setStreaming(true);
setErrorMessage(null); 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();
@@ -86,6 +103,7 @@ 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) {
@@ -93,7 +111,8 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
} else { } else {
setErrorMessage('Mất kết nối giám sát — đang thử lại...'); setErrorMessage('Mất kết nối giám sát — đang thử lại...');
} }
retryTimer = setTimeout(connect, 3000); const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000);
retryTimer = setTimeout(connect, delay);
}; };
}; };
@@ -101,6 +120,7 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
return () => { return () => {
intentionalClose.current = true; intentionalClose.current = true;
clearPing();
if (retryTimer) clearTimeout(retryTimer); if (retryTimer) clearTimeout(retryTimer);
if (wsRef.current?.readyState === WebSocket.OPEN) { if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } })); wsRef.current.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { 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

@@ -13,8 +13,11 @@ import (
) )
const ( const (
wsPingInterval = 30 * time.Second // Heartbeat kiểu game: phát hiện mất kết nối trong ~3045s.
wsPongWait = 60 * time.Second 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 {
@@ -44,6 +47,10 @@ func (c *SocketClient) WriteRaw(msg []byte) error {
return c.Conn.WriteMessage(websocket.TextMessage, msg) 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
@@ -51,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{
@@ -58,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) {
@@ -102,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{}
} }
@@ -124,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"})
@@ -151,19 +281,17 @@ func (h *WsHub) Unregister(c *SocketClient) {
return 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).
}
}
}
} 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 {
@@ -182,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 {
@@ -192,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"})
@@ -204,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 {
@@ -223,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()
@@ -250,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"})
@@ -261,7 +381,6 @@ 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()
teachersList, exists := h.subscribers[studentID] teachersList, exists := h.subscribers[studentID]
@@ -269,7 +388,6 @@ func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
h.mu.RUnlock() h.mu.RUnlock()
return return
} }
// Snapshot subscriber addresses while holding read lock
addrs := make([]string, len(teachersList)) addrs := make([]string, len(teachersList))
copy(addrs, teachersList) copy(addrs, teachersList)
h.mu.RUnlock() h.mu.RUnlock()
@@ -291,18 +409,30 @@ func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
return return
} }
var dead []string
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock()
for _, addr := range addrs { for _, addr := range addrs {
if t, found := h.teachers[addr]; found { if t, found := h.teachers[addr]; found {
if err := t.WriteRaw(msgBytes); err != nil { if err := t.WriteRaw(msgBytes); err != nil {
log.Printf("[WS] Relay to teacher %s failed: %v", addr, err) 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")
@@ -351,7 +481,7 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
return return
case <-ticker.C: case <-ticker.C:
client.writeMu.Lock() client.writeMu.Lock()
err := c.WriteMessage(websocket.PingMessage, nil) err := c.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(5*time.Second))
client.writeMu.Unlock() client.writeMu.Unlock()
if err != nil { if err != nil {
return return
@@ -372,14 +502,14 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
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
@@ -396,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))