This commit is contained in:
454
client/app.go
454
client/app.go
@@ -125,8 +125,13 @@ type App struct {
|
||||
sessionPath string
|
||||
statsPath string
|
||||
webviewDataPath string
|
||||
pendingViolPath string
|
||||
runLockPath string
|
||||
wsConn *websocket.Conn
|
||||
wsConnected bool
|
||||
wsConnecting bool
|
||||
wsBackoff time.Duration
|
||||
wsWriteMu sync.Mutex
|
||||
wifiSSID string
|
||||
wifiBSSID string
|
||||
allowedApps string
|
||||
@@ -152,7 +157,6 @@ type App struct {
|
||||
chatUnread int
|
||||
replyStaffID uint
|
||||
fetchAppsMu sync.Mutex
|
||||
wsWriteMu sync.Mutex
|
||||
}
|
||||
|
||||
type LocalStats struct {
|
||||
@@ -216,6 +220,8 @@ func NewApp() *App {
|
||||
sessionPath: filepath.Join(appDir, "student_session.json"),
|
||||
statsPath: filepath.Join(appDir, "student_stats.json"),
|
||||
webviewDataPath: webviewDir,
|
||||
pendingViolPath: filepath.Join(appDir, "pending_violations.json"),
|
||||
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.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)
|
||||
winapi.RequestLocationAccess()
|
||||
winapi.RequestCameraAndMicAccess()
|
||||
@@ -268,10 +278,35 @@ func (a *App) startup(ctx context.Context) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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).
|
||||
func (a *App) tearDownBeforeQuit() {
|
||||
a.mu.Lock()
|
||||
@@ -287,6 +322,7 @@ func (a *App) tearDownBeforeQuit() {
|
||||
a.stopWebcamStream()
|
||||
a.disconnectWS()
|
||||
guard.Stop()
|
||||
a.clearRunLock()
|
||||
}
|
||||
|
||||
func (a *App) isMonitoringActive() bool {
|
||||
@@ -315,6 +351,162 @@ func (a *App) showQuitDialog(title, message string) {
|
||||
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
|
||||
func (a *App) startLocalServer() {
|
||||
mux := http.NewServeMux()
|
||||
@@ -392,6 +584,16 @@ func (a *App) startLocalServer() {
|
||||
}()
|
||||
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() {
|
||||
_ = server.ListenAndServe()
|
||||
@@ -408,26 +610,95 @@ const examViewHTML = `<!DOCTYPE html>
|
||||
<style>
|
||||
*{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}
|
||||
.bar{display:flex;align-items:center;gap:12px;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 button:hover{background:#6d28d9}
|
||||
.bar span{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#0f172a;border-bottom:1px solid #334155;flex-shrink:0}
|
||||
.bar-title{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:auto;min-width:0}
|
||||
.bar button{border:none;border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer;font-weight:600;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}
|
||||
iframe{width:100%%;height:100%%;border:0;display:block}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bar">
|
||||
<button type="button" onclick="goBack()">← Quay lại</button>
|
||||
<span>%s</span>
|
||||
<button type="button" class="btn-back" onclick="goBack()">← Quay lại</button>
|
||||
<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 class="frame-wrap">
|
||||
<iframe src="%s" title="exam-content"></iframe>
|
||||
<iframe id="exam-frame" src="%s" title="exam-content"></iframe>
|
||||
</div>
|
||||
<script>
|
||||
function goBack(){
|
||||
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>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -1017,6 +1288,7 @@ func (a *App) rejectUnauthorizedWifi(ssid, bssid string) {
|
||||
a.wifiRejected = true
|
||||
a.mu.Unlock()
|
||||
|
||||
a.reportViolation("wifi", fmt.Sprintf("WiFi không được phép: %s (%s)", ssid, bssid))
|
||||
a.showQuitDialog(
|
||||
"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),
|
||||
@@ -1132,40 +1404,101 @@ func (a *App) connectWS() {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
if a.wsConnected || a.student == nil {
|
||||
if a.wsConnected || a.wsConnecting || a.student == nil {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
a.wsConnecting = true
|
||||
student := a.student
|
||||
classID := a.dashboard.ClassRkID
|
||||
if classID <= 0 {
|
||||
classID = student.SystemID
|
||||
}
|
||||
backoff := a.wsBackoff
|
||||
if backoff <= 0 {
|
||||
backoff = 500 * time.Millisecond
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
wsUrl := fmt.Sprintf("%s/ws?role=student&studentId=%d&classId=%d", getWsUrl(API_BASE), student.StudentID, classID)
|
||||
dialer := websocket.Dialer{HandshakeTimeout: 4 * time.Second}
|
||||
conn, _, err := dialer.Dial(wsUrl, 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
|
||||
}
|
||||
|
||||
_ = 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.wsConn = conn
|
||||
a.wsConnected = true
|
||||
a.wsConnecting = false
|
||||
a.wsBackoff = 500 * time.Millisecond
|
||||
a.mu.Unlock()
|
||||
|
||||
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() {
|
||||
defer close(pingDone)
|
||||
defer func() {
|
||||
a.disconnectWS(conn)
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
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 {
|
||||
var msg struct {
|
||||
@@ -1177,6 +1510,10 @@ func (a *App) connectWS() {
|
||||
log.Printf("[WS] ReadJSON error: %v", err)
|
||||
break
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(45 * time.Second))
|
||||
if msg.Event == "client:pong" {
|
||||
continue
|
||||
}
|
||||
log.Printf("[WS] Received event: %s", msg.Event)
|
||||
|
||||
switch msg.Event {
|
||||
@@ -1238,6 +1575,7 @@ func (a *App) disconnectWS(expectedConn ...*websocket.Conn) {
|
||||
a.wsConn = nil
|
||||
}
|
||||
a.wsConnected = false
|
||||
a.wsConnecting = false
|
||||
shouldStopStreams = a.isStreamingSc || a.isStreamingCam
|
||||
a.mu.Unlock()
|
||||
|
||||
@@ -1523,6 +1861,83 @@ func (a *App) ReturnToDashboard() {
|
||||
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 {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Host == "" {
|
||||
@@ -1533,11 +1948,17 @@ func isLocalExamURL(raw string) bool {
|
||||
}
|
||||
|
||||
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)
|
||||
if targetURL == "" {
|
||||
return errors.New("không có nội dung để mở")
|
||||
}
|
||||
if isLocalExamURL(targetURL) {
|
||||
if forceWrap || isLocalExamURL(targetURL) {
|
||||
wrapper := fmt.Sprintf(
|
||||
"http://127.0.0.1:34115/exam-view?url=%s&title=%s",
|
||||
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))
|
||||
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)
|
||||
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL))
|
||||
return nil
|
||||
@@ -1639,7 +2060,8 @@ func (a *App) OpenExamQuiz() error {
|
||||
if err != nil {
|
||||
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 {
|
||||
|
||||
@@ -413,7 +413,7 @@ function renderExamPanel() {
|
||||
<div class="card-title-row">
|
||||
<div class="card-title">📝 ${ex.examName || 'Phòng thi'}</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">
|
||||
${paperBtn}
|
||||
${quizBtn}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
var (
|
||||
guardOnce sync.Once
|
||||
guardViolation func(string)
|
||||
guardViolation func(kind, reason string)
|
||||
guardStop chan struct{}
|
||||
suppressMu sync.Mutex
|
||||
suppressViolationsUntil time.Time
|
||||
@@ -36,8 +36,8 @@ func violationsSuppressed() bool {
|
||||
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).
|
||||
func Start(onViolation func(reason string)) {
|
||||
// Start giám sát môi trường macOS — vi phạm thì gọi onViolation(kind, reason).
|
||||
func Start(onViolation func(kind, reason string)) {
|
||||
guardOnce.Do(func() {
|
||||
if onViolation == nil {
|
||||
return
|
||||
@@ -46,7 +46,7 @@ func Start(onViolation func(reason string)) {
|
||||
guardStop = make(chan struct{})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func checkEnvironment() {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
var (
|
||||
guardOnce sync.Once
|
||||
guardViolation func(string)
|
||||
guardViolation func(kind, reason string)
|
||||
guardStop chan struct{}
|
||||
suppressMu sync.Mutex
|
||||
suppressViolationsUntil time.Time
|
||||
@@ -37,7 +37,7 @@ func violationsSuppressed() bool {
|
||||
}
|
||||
|
||||
// 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() {
|
||||
if onViolation == nil {
|
||||
return
|
||||
@@ -46,7 +46,7 @@ func Start(onViolation func(reason string)) {
|
||||
guardStop = make(chan struct{})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func checkEnvironment() {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@ package guard
|
||||
|
||||
import "time"
|
||||
|
||||
func Start(onViolation func(reason string)) {}
|
||||
func Start(onViolation func(kind, reason string)) {}
|
||||
func Stop() {}
|
||||
func SuppressFor(_ time.Duration) {}
|
||||
|
||||
@@ -73,7 +73,7 @@ type msg struct {
|
||||
|
||||
var (
|
||||
guardOnce sync.Once
|
||||
guardViolation func(string)
|
||||
guardViolation func(kind, reason string)
|
||||
guardStop chan struct{}
|
||||
guardBaselineUser string
|
||||
guardBaselineSession uint32
|
||||
@@ -102,8 +102,8 @@ func violationsSuppressed() bool {
|
||||
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).
|
||||
func Start(onViolation func(reason string)) {
|
||||
// Start giám sát môi trường Windows — vi phạm thì gọi onViolation(kind, reason).
|
||||
func Start(onViolation func(kind, reason string)) {
|
||||
guardOnce.Do(func() {
|
||||
if onViolation == nil {
|
||||
return
|
||||
@@ -114,7 +114,7 @@ func Start(onViolation func(reason string)) {
|
||||
guardBaselineSession = currentSessionID()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -147,27 +147,27 @@ func checkEnvironment() {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
user := currentUsername()
|
||||
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
|
||||
}
|
||||
sid := currentSessionID()
|
||||
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() {
|
||||
log.Printf("[GUARD] suppressed: %s", reason)
|
||||
return
|
||||
}
|
||||
if guardViolation != nil {
|
||||
guardViolation(reason)
|
||||
guardViolation(kind, reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr {
|
||||
case wmWtsSessionChange:
|
||||
switch uint32(wParam) {
|
||||
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)
|
||||
@@ -271,7 +271,7 @@ func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr {
|
||||
|
||||
func desktopSwitchCallback(hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime uintptr) uintptr {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"log"
|
||||
"os"
|
||||
@@ -46,6 +47,12 @@ func main() {
|
||||
fileMenu.AddText("Về trang chính", keys.CmdOrCtrl("h"), func(_ *menu.CallbackData) {
|
||||
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
|
||||
err := wails.Run(&options.App{
|
||||
@@ -58,6 +65,9 @@ func main() {
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
||||
OnStartup: app.startup,
|
||||
OnBeforeClose: func(ctx context.Context) (prevent bool) {
|
||||
return app.HandleBeforeClose()
|
||||
},
|
||||
Windows: &windows.Options{
|
||||
WebviewUserDataPath: app.webviewDataPath,
|
||||
},
|
||||
|
||||
@@ -381,6 +381,31 @@ export interface StudentSessionLogItem {
|
||||
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 = {
|
||||
getStats: async (): Promise<StatsResponse> => {
|
||||
const res = await staffFetch('/stats');
|
||||
@@ -716,6 +741,30 @@ export const apiFetchClassSessionLogs = async (
|
||||
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[] }> => {
|
||||
const res = await staffFetch(`/classes/${rkId}/online-students`);
|
||||
if (!res.ok) throw new Error('Failed to fetch online students list');
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api';
|
||||
import { type StaffChatOpenDetail } from '../chatEvents';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { onStaffChatMessage, playChatSound } from '../hooks/useStaffChatSocket';
|
||||
import { onStaffChatMessage, onStudentViolation, playChatSound, kindLabel } from '../hooks/useStaffChatSocket';
|
||||
|
||||
export function ChatWidget() {
|
||||
const { staff } = useAuth();
|
||||
@@ -83,6 +83,13 @@ export function ChatWidget() {
|
||||
return () => { off(); };
|
||||
}, [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(() => {
|
||||
const onOpen = (e: Event) => {
|
||||
const detail = (e as CustomEvent<StaffChatOpenDetail>).detail;
|
||||
@@ -152,7 +159,7 @@ export function ChatWidget() {
|
||||
const dock = (
|
||||
<div className="chat-dock">
|
||||
{toast && (
|
||||
<div className="chat-toast" role="status">
|
||||
<div className={`chat-toast ${toast.title.startsWith('⚠') ? 'chat-toast-alert' : ''}`} role="status">
|
||||
<strong>{toast.title}</strong>
|
||||
<span>{toast.body}</span>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,8 @@ import { AttendancePanel } from './AttendancePanel';
|
||||
import { StudentDetailModal } from './StudentDetailModal';
|
||||
import { ExamGridProctor } from './ExamGridProctor';
|
||||
import { AppPoolModal } from './AppPoolModal';
|
||||
import { onStudentPresence } from '../hooks/useStaffChatSocket';
|
||||
import { ViolationsPanel } from './ViolationsPanel';
|
||||
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
|
||||
import { mergeKeywordCSV } from '../utils/appKeywords';
|
||||
import { WorkspaceSeatingChart } from './WorkspaceSeatingChart';
|
||||
@@ -52,7 +54,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [logsLoading, setLogsLoading] = 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 [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
|
||||
const [appPoolOpen, setAppPoolOpen] = useState(false);
|
||||
@@ -144,10 +146,21 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
||||
const interval = setInterval(() => {
|
||||
fetchOnlineStatus();
|
||||
fetchLogs(logDate, logPeriod);
|
||||
}, 5000);
|
||||
}, 8000);
|
||||
return () => clearInterval(interval);
|
||||
}, [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(() => {
|
||||
loadLogShifts(logDate);
|
||||
if (activeSubTab === 'logs') {
|
||||
@@ -400,6 +413,12 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
||||
>
|
||||
Nhật ký theo ca
|
||||
</button>
|
||||
<button
|
||||
className={`tab-sub-btn ${activeSubTab === 'violations' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSubTab('violations')}
|
||||
>
|
||||
Vi phạm
|
||||
</button>
|
||||
<button
|
||||
className={`tab-sub-btn ${activeSubTab === 'attendance' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSubTab('attendance')}
|
||||
@@ -410,7 +429,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
||||
</div>
|
||||
|
||||
{/* Student Search */}
|
||||
{activeSubTab !== 'attendance' && activeSubTab !== 'grid' && <div className="search-input-wrapper">
|
||||
{activeSubTab !== 'attendance' && activeSubTab !== 'grid' && activeSubTab !== 'violations' && <div className="search-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
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>
|
||||
|
||||
<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' ? (
|
||||
<AttendancePanel classId={classId} />
|
||||
) : activeSubTab === 'violations' ? (
|
||||
<ViolationsPanel mode="class" classId={classId} />
|
||||
) : activeSubTab === 'grid' ? (
|
||||
<ExamGridProctor
|
||||
students={students.map((s) => ({
|
||||
|
||||
@@ -56,6 +56,15 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
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;
|
||||
@@ -64,11 +73,21 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
wsRef.current = ws;
|
||||
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) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.event === 'client:pong') return;
|
||||
if (msg.event === 'teacher:screenshot-stream-frame') {
|
||||
const { studentId, imageBuffer } = msg.data;
|
||||
setScreenFrames((prev) => ({ ...prev, [studentId]: imageBuffer }));
|
||||
@@ -94,9 +113,11 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearPing();
|
||||
subscribedRef.current = new Set();
|
||||
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 () => {
|
||||
closed = true;
|
||||
clearPing();
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
|
||||
@@ -20,6 +20,8 @@ import { StudentDetailModal } from './StudentDetailModal';
|
||||
import { fmtTime, localInputToISO, toLocalInput } from './ExamsTab';
|
||||
import { ExamGridProctor } from './ExamGridProctor';
|
||||
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'])];
|
||||
|
||||
@@ -88,7 +90,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
const [onlineIds, setOnlineIds] = useState<number[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
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 [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info');
|
||||
const [papersModalOpen, setPapersModalOpen] = useState(false);
|
||||
@@ -163,10 +165,23 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchOnline();
|
||||
const t = setInterval(fetchOnline, 5000);
|
||||
const t = setInterval(fetchOnline, 8000);
|
||||
return () => clearInterval(t);
|
||||
}, [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(() => {
|
||||
if (!studentPickerOpen || !searchQ.trim()) {
|
||||
if (!studentPickerOpen) setSearchHits([]);
|
||||
@@ -448,6 +463,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
};
|
||||
|
||||
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('');
|
||||
try {
|
||||
await apiExam.publish(examId);
|
||||
@@ -880,11 +901,18 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
>
|
||||
Bài nộp ({submissions.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${activeSubTab === 'violations' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSubTab('violations')}
|
||||
>
|
||||
Vi phạm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<input
|
||||
type="text"
|
||||
@@ -1014,6 +1042,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : activeSubTab === 'violations' ? (
|
||||
<ViolationsPanel mode="exam" examId={examId} />
|
||||
) : (
|
||||
<div className="session-logs-panel">
|
||||
{submissions.length > 0 && (
|
||||
|
||||
@@ -33,6 +33,8 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
useEffect(() => {
|
||||
const wsUrl = getWsUrl('/ws?role=teacher');
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let attempt = 0;
|
||||
|
||||
intentionalClose.current = false;
|
||||
hasOpened.current = false;
|
||||
@@ -50,6 +52,13 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const clearPing = () => {
|
||||
if (pingTimer) {
|
||||
clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (intentionalClose.current) return;
|
||||
|
||||
@@ -57,15 +66,23 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
attempt = 0;
|
||||
hasOpened.current = true;
|
||||
setStreaming(true);
|
||||
setErrorMessage(null);
|
||||
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) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.event === 'client:pong') return;
|
||||
if (msg.event === 'teacher:screenshot-stream-frame' && msg.data.studentId === studentId) {
|
||||
setScreenFrame(msg.data.imageBuffer);
|
||||
markStreaming();
|
||||
@@ -86,6 +103,7 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
ws.onerror = () => {};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearPing();
|
||||
if (intentionalClose.current) return;
|
||||
setStreaming(false);
|
||||
if (!hasOpened.current && !hasFrames.current) {
|
||||
@@ -93,7 +111,8 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
} else {
|
||||
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 () => {
|
||||
intentionalClose.current = true;
|
||||
clearPing();
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } }));
|
||||
|
||||
127
management/src/components/ViolationsPanel.tsx
Normal file
127
management/src/components/ViolationsPanel.tsx
Normal 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 có 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>Mã 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>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +1,59 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { type ChatMessage, getWsUrl } from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { playChatSound } from '../utils/notifySound';
|
||||
import { playChatSound, playAlertSound } from '../utils/notifySound';
|
||||
|
||||
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 {
|
||||
handlers.add(handler);
|
||||
return () => { handlers.delete(handler); };
|
||||
chatHandlers.add(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() {
|
||||
const { staff, token } = useAuth();
|
||||
const staffIdRef = useRef(0);
|
||||
@@ -21,8 +62,8 @@ export function StaffChatSocket() {
|
||||
staffIdRef.current = staff?.id ?? 0;
|
||||
}, [staff?.id]);
|
||||
|
||||
const dispatch = useCallback((msg: ChatMessage) => {
|
||||
handlers.forEach((h) => h(msg));
|
||||
const dispatchChat = useCallback((msg: ChatMessage) => {
|
||||
chatHandlers.forEach((h) => h(msg));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,26 +72,74 @@ export function StaffChatSocket() {
|
||||
const wsUrl = getWsUrl(`/ws?role=teacher&staffId=${staff.id}`);
|
||||
let ws: WebSocket | null = null;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let closed = false;
|
||||
let attempt = 0;
|
||||
|
||||
const clearPing = () => {
|
||||
if (pingTimer) {
|
||||
clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return;
|
||||
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) => {
|
||||
try {
|
||||
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;
|
||||
const msg = payload.data as ChatMessage;
|
||||
const targetStaff = Number(msg.targetStaffId ?? msg.staffId ?? 0);
|
||||
if (targetStaff > 0 && targetStaff !== staffIdRef.current) return;
|
||||
dispatch(msg);
|
||||
dispatchChat(msg);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearPing();
|
||||
if (!closed) {
|
||||
retryTimer = setTimeout(connect, 3000);
|
||||
retryTimer = setTimeout(connect, scheduleBackoff(attempt++));
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -59,12 +148,13 @@ export function StaffChatSocket() {
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
clearPing();
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
ws?.close();
|
||||
};
|
||||
}, [token, staff?.id, dispatch]);
|
||||
}, [token, staff?.id, dispatchChat]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export { playChatSound };
|
||||
export { playChatSound, playAlertSound };
|
||||
|
||||
@@ -3874,6 +3874,15 @@ input:checked + .slider:before {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.chat-toast-alert {
|
||||
background: #7f1d1d;
|
||||
border: 1px solid #f87171;
|
||||
}
|
||||
|
||||
.chat-toast-alert strong {
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
@keyframes chat-toast-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
|
||||
@@ -35,3 +35,29 @@ export function playChatSound() {
|
||||
playTone(880, t, 0.12);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&models.WifiPoolEntry{},
|
||||
&models.AcceptedWifi{},
|
||||
&models.StudentSession{},
|
||||
&models.StudentViolation{},
|
||||
&models.AttendanceResult{},
|
||||
&models.StaffAccount{},
|
||||
&models.EmailDomain{},
|
||||
|
||||
@@ -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)
|
||||
func ListAppPoolHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
|
||||
@@ -179,6 +179,20 @@ type StudentSession struct {
|
||||
|
||||
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
|
||||
type SystemSetting struct {
|
||||
SettingKey string `gorm:"primaryKey;column:setting_key;size:128" json:"settingKey"`
|
||||
|
||||
@@ -13,8 +13,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
wsPingInterval = 30 * time.Second
|
||||
wsPongWait = 60 * time.Second
|
||||
// Heartbeat kiểu game: phát hiện mất kết nối trong ~30–45s.
|
||||
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 {
|
||||
@@ -44,6 +47,10 @@ func (c *SocketClient) WriteRaw(msg []byte) error {
|
||||
return c.Conn.WriteMessage(websocket.TextMessage, msg)
|
||||
}
|
||||
|
||||
type offlineGrace struct {
|
||||
until time.Time
|
||||
classID int64
|
||||
}
|
||||
|
||||
type WsHub struct {
|
||||
mu sync.RWMutex
|
||||
@@ -51,6 +58,8 @@ type WsHub struct {
|
||||
teachers map[string]*SocketClient
|
||||
teachersByStaff map[uint][]string // staffId -> teacher connection addresses
|
||||
subscribers map[int64][]string // studentId -> list of teacher connection addresses
|
||||
grace map[int64]offlineGrace
|
||||
graceTimers map[int64]*time.Timer
|
||||
}
|
||||
|
||||
var Hub = &WsHub{
|
||||
@@ -58,13 +67,18 @@ var Hub = &WsHub{
|
||||
teachers: make(map[string]*SocketClient),
|
||||
teachersByStaff: make(map[uint][]string),
|
||||
subscribers: make(map[int64][]string),
|
||||
grace: make(map[int64]offlineGrace),
|
||||
graceTimers: make(map[int64]*time.Timer),
|
||||
}
|
||||
|
||||
func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
_, ok := h.students[studentRkID]
|
||||
return ok
|
||||
if _, ok := h.students[studentRkID]; 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) {
|
||||
@@ -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 {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
seen := map[int64]bool{}
|
||||
var ids []int64
|
||||
now := time.Now()
|
||||
for _, client := range h.students {
|
||||
if client.ClassID == classID {
|
||||
seen[client.StudentID] = true
|
||||
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 {
|
||||
return []int64{}
|
||||
}
|
||||
@@ -124,8 +248,14 @@ func (h *WsHub) Register(c *SocketClient) {
|
||||
|
||||
c.Addr = c.Conn.RemoteAddr().String()
|
||||
if c.Role == "student" {
|
||||
wasInGrace := false
|
||||
if _, ok := h.grace[c.StudentID]; ok {
|
||||
wasInGrace = true
|
||||
}
|
||||
h.cancelGraceLocked(c.StudentID)
|
||||
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 {
|
||||
_ = c.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
||||
_ = c.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
||||
@@ -151,19 +281,17 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
||||
return
|
||||
}
|
||||
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
|
||||
if teachers, exists := h.subscribers[c.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": c.StudentID},
|
||||
h.cancelGraceLocked(studentID)
|
||||
deadline := time.Now().Add(studentOfflineGrace)
|
||||
h.grace[studentID] = offlineGrace{until: deadline, classID: classID}
|
||||
h.graceTimers[studentID] = time.AfterFunc(studentOfflineGrace, func() {
|
||||
h.finalizeStudentOffline(studentID, classID)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
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" {
|
||||
delete(h.teachers, c.Addr)
|
||||
if c.StaffID > 0 {
|
||||
@@ -182,7 +310,6 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
||||
}
|
||||
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 {
|
||||
newList := []string{}
|
||||
for _, addr := range teachersList {
|
||||
@@ -192,7 +319,6 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
||||
}
|
||||
if len(newList) == 0 {
|
||||
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 {
|
||||
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_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) {
|
||||
h.mu.Lock()
|
||||
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]
|
||||
alreadySubscribed := false
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
_ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
||||
_ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
||||
}
|
||||
}
|
||||
|
||||
// Teacher dừng xem stream của Student
|
||||
func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -250,8 +372,6 @@ func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
|
||||
if len(newList) == 0 {
|
||||
delete(h.subscribers, 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 {
|
||||
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_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) {
|
||||
h.mu.RLock()
|
||||
teachersList, exists := h.subscribers[studentID]
|
||||
@@ -269,7 +388,6 @@ func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
// Snapshot subscriber addresses while holding read lock
|
||||
addrs := make([]string, len(teachersList))
|
||||
copy(addrs, teachersList)
|
||||
h.mu.RUnlock()
|
||||
@@ -291,18 +409,30 @@ func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
|
||||
return
|
||||
}
|
||||
|
||||
var dead []string
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for _, addr := range addrs {
|
||||
if t, found := h.teachers[addr]; found {
|
||||
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) {
|
||||
return func(c *websocket.Conn) {
|
||||
role := c.Query("role", "student")
|
||||
@@ -351,7 +481,7 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
return
|
||||
case <-ticker.C:
|
||||
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()
|
||||
if err != nil {
|
||||
return
|
||||
@@ -372,14 +502,14 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Xử lý các sự kiện
|
||||
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":
|
||||
// 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)
|
||||
|
||||
case "teacher:subscribe":
|
||||
// Giáo viên đăng ký xem học sinh cụ thể
|
||||
if client.Role == "teacher" {
|
||||
if sIDVal, ok := msg.Data["studentId"]; ok {
|
||||
var sID int64
|
||||
@@ -396,7 +526,6 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
}
|
||||
|
||||
case "teacher:unsubscribe":
|
||||
// Giáo viên hủy đăng ký
|
||||
if client.Role == "teacher" {
|
||||
if sIDVal, ok := msg.Data["studentId"]; ok {
|
||||
var sID int64
|
||||
|
||||
@@ -93,6 +93,7 @@ func main() {
|
||||
api.Get("/student/wifi-policy", handlers.GetStudentWifiPolicyHandler(gormDB))
|
||||
api.Post("/student/report-wifi", handlers.ReportWifiHandler(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.Post("/student/chat/messages", handlers.StudentSendChatHandler(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.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(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/schedule-conflicts", handlers.GetClassScheduleConflictsHandler(gormDB))
|
||||
|
||||
@@ -187,6 +189,7 @@ func main() {
|
||||
staff.Get("/exam-rooms/search-students", handlers.SearchExamStudentsHandler(gormDB))
|
||||
staff.Get("/exam-rooms/:id", handlers.GetExamRoomHandler(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.Delete("/exam-rooms/:id", handlers.DeleteExamRoomHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/students", handlers.AddExamRoomStudentsHandler(gormDB))
|
||||
|
||||
Reference in New Issue
Block a user