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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 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) => ({

View File

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

View File

@@ -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 && (

View File

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

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

View File

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

View File

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

View File

@@ -53,6 +53,7 @@ func AutoMigrate(db *gorm.DB) error {
&models.WifiPoolEntry{},
&models.AcceptedWifi{},
&models.StudentSession{},
&models.StudentViolation{},
&models.AttendanceResult{},
&models.StaffAccount{},
&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)
func ListAppPoolHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {

View File

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

View File

@@ -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 ~3045s.
wsPingInterval = 15 * time.Second
wsPongWait = 45 * time.Second
// Grace khi SV reconnect — tránh nhấp nháy online/offline.
studentOfflineGrace = 8 * time.Second
)
type SocketMsg struct {
@@ -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

View File

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