This commit is contained in:
454
client/app.go
454
client/app.go
@@ -125,8 +125,13 @@ type App struct {
|
|||||||
sessionPath string
|
sessionPath string
|
||||||
statsPath string
|
statsPath string
|
||||||
webviewDataPath string
|
webviewDataPath string
|
||||||
|
pendingViolPath string
|
||||||
|
runLockPath string
|
||||||
wsConn *websocket.Conn
|
wsConn *websocket.Conn
|
||||||
wsConnected bool
|
wsConnected bool
|
||||||
|
wsConnecting bool
|
||||||
|
wsBackoff time.Duration
|
||||||
|
wsWriteMu sync.Mutex
|
||||||
wifiSSID string
|
wifiSSID string
|
||||||
wifiBSSID string
|
wifiBSSID string
|
||||||
allowedApps string
|
allowedApps string
|
||||||
@@ -152,7 +157,6 @@ type App struct {
|
|||||||
chatUnread int
|
chatUnread int
|
||||||
replyStaffID uint
|
replyStaffID uint
|
||||||
fetchAppsMu sync.Mutex
|
fetchAppsMu sync.Mutex
|
||||||
wsWriteMu sync.Mutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocalStats struct {
|
type LocalStats struct {
|
||||||
@@ -216,6 +220,8 @@ func NewApp() *App {
|
|||||||
sessionPath: filepath.Join(appDir, "student_session.json"),
|
sessionPath: filepath.Join(appDir, "student_session.json"),
|
||||||
statsPath: filepath.Join(appDir, "student_stats.json"),
|
statsPath: filepath.Join(appDir, "student_stats.json"),
|
||||||
webviewDataPath: webviewDir,
|
webviewDataPath: webviewDir,
|
||||||
|
pendingViolPath: filepath.Join(appDir, "pending_violations.json"),
|
||||||
|
runLockPath: filepath.Join(appDir, "monitoring.lock"),
|
||||||
lastSyncTime: time.Now(),
|
lastSyncTime: time.Now(),
|
||||||
expectingLogin: false,
|
expectingLogin: false,
|
||||||
}
|
}
|
||||||
@@ -226,6 +232,10 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.loadSession()
|
a.loadSession()
|
||||||
a.loadStats()
|
a.loadStats()
|
||||||
|
|
||||||
|
// Task Manager / kill process lần trước → còn file lock → báo unclean_shutdown
|
||||||
|
a.detectUncleanShutdown()
|
||||||
|
go a.flushPendingViolations()
|
||||||
|
|
||||||
// Request Location Access (macOS)
|
// Request Location Access (macOS)
|
||||||
winapi.RequestLocationAccess()
|
winapi.RequestLocationAccess()
|
||||||
winapi.RequestCameraAndMicAccess()
|
winapi.RequestCameraAndMicAccess()
|
||||||
@@ -268,10 +278,35 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
go a.authStorageScanner()
|
go a.authStorageScanner()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleGuardViolation(reason string) {
|
func (a *App) handleGuardViolation(kind, reason string) {
|
||||||
|
if kind == "" {
|
||||||
|
kind = "guard"
|
||||||
|
}
|
||||||
|
a.reportViolation(kind, reason)
|
||||||
a.showQuitDialog("Vi phạm giám sát", reason)
|
a.showQuitDialog("Vi phạm giám sát", reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleBeforeClose — SV bấm X / Alt+F4 khi đang giám sát = vi phạm.
|
||||||
|
// Trả về false để cho phép đóng sau khi đã báo cáo.
|
||||||
|
func (a *App) HandleBeforeClose() (prevent bool) {
|
||||||
|
if !a.CheckLoginStatus() || !a.isMonitoringActive() {
|
||||||
|
a.clearRunLock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
mode := a.dashboard.MonitorMode
|
||||||
|
a.mu.Unlock()
|
||||||
|
if mode == "" || mode == "not_configured" {
|
||||||
|
a.clearRunLock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
a.reportViolation("app_closed", "Sinh viên tự đóng ứng dụng khi đang giám sát ("+mode+")")
|
||||||
|
a.tearDownBeforeQuit()
|
||||||
|
a.clearRunLock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// tearDownBeforeQuit ngắt mọi kênh giám sát ngay — trước khi hiện dialog (tránh treo OK để duy trì kết nối).
|
// tearDownBeforeQuit ngắt mọi kênh giám sát ngay — trước khi hiện dialog (tránh treo OK để duy trì kết nối).
|
||||||
func (a *App) tearDownBeforeQuit() {
|
func (a *App) tearDownBeforeQuit() {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
@@ -287,6 +322,7 @@ func (a *App) tearDownBeforeQuit() {
|
|||||||
a.stopWebcamStream()
|
a.stopWebcamStream()
|
||||||
a.disconnectWS()
|
a.disconnectWS()
|
||||||
guard.Stop()
|
guard.Stop()
|
||||||
|
a.clearRunLock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) isMonitoringActive() bool {
|
func (a *App) isMonitoringActive() bool {
|
||||||
@@ -315,6 +351,162 @@ func (a *App) showQuitDialog(title, message string) {
|
|||||||
runtime.Quit(a.ctx)
|
runtime.Quit(a.ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type pendingViolation struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
MonitorMode string `json:"monitorMode"`
|
||||||
|
ClientAt string `json:"clientAt"`
|
||||||
|
StudentRkID int64 `json:"studentRkId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) markRunLock() {
|
||||||
|
a.mu.Lock()
|
||||||
|
studentID := int64(0)
|
||||||
|
mode := a.dashboard.MonitorMode
|
||||||
|
if a.student != nil {
|
||||||
|
studentID = a.student.StudentID
|
||||||
|
}
|
||||||
|
a.mu.Unlock()
|
||||||
|
if studentID <= 0 || mode == "" || mode == "not_configured" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"studentRkId": studentID,
|
||||||
|
"monitorMode": mode,
|
||||||
|
"startedAt": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
_ = os.WriteFile(a.runLockPath, payload, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) clearRunLock() {
|
||||||
|
_ = os.Remove(a.runLockPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) detectUncleanShutdown() {
|
||||||
|
data, err := os.ReadFile(a.runLockPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Remove(a.runLockPath)
|
||||||
|
|
||||||
|
var meta struct {
|
||||||
|
StudentRkID int64 `json:"studentRkId"`
|
||||||
|
MonitorMode string `json:"monitorMode"`
|
||||||
|
StartedAt string `json:"startedAt"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(data, &meta)
|
||||||
|
if meta.StudentRkID <= 0 {
|
||||||
|
a.mu.Lock()
|
||||||
|
if a.student != nil {
|
||||||
|
meta.StudentRkID = a.student.StudentID
|
||||||
|
}
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
if meta.StudentRkID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reason := "Ứng dụng bị tắt đột ngột (Task Manager / kill process) khi đang giám sát"
|
||||||
|
if meta.MonitorMode != "" {
|
||||||
|
reason += " (" + meta.MonitorMode + ")"
|
||||||
|
}
|
||||||
|
a.enqueuePendingViolation(pendingViolation{
|
||||||
|
Kind: "unclean_shutdown",
|
||||||
|
Reason: reason,
|
||||||
|
MonitorMode: meta.MonitorMode,
|
||||||
|
ClientAt: time.Now().Format(time.RFC3339),
|
||||||
|
StudentRkID: meta.StudentRkID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) enqueuePendingViolation(v pendingViolation) {
|
||||||
|
list := a.loadPendingViolations()
|
||||||
|
list = append(list, v)
|
||||||
|
raw, _ := json.Marshal(list)
|
||||||
|
_ = os.WriteFile(a.pendingViolPath, raw, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loadPendingViolations() []pendingViolation {
|
||||||
|
data, err := os.ReadFile(a.pendingViolPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var list []pendingViolation
|
||||||
|
if json.Unmarshal(data, &list) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) flushPendingViolations() {
|
||||||
|
list := a.loadPendingViolations()
|
||||||
|
if len(list) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remaining := make([]pendingViolation, 0)
|
||||||
|
for _, v := range list {
|
||||||
|
if !a.postViolation(v) {
|
||||||
|
remaining = append(remaining, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(remaining) == 0 {
|
||||||
|
_ = os.Remove(a.pendingViolPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(remaining)
|
||||||
|
_ = os.WriteFile(a.pendingViolPath, raw, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) postViolation(v pendingViolation) bool {
|
||||||
|
if v.StudentRkID <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"studentRkId": v.StudentRkID,
|
||||||
|
"kind": v.Kind,
|
||||||
|
"reason": v.Reason,
|
||||||
|
"monitorMode": v.MonitorMode,
|
||||||
|
"clientAt": v.ClientAt,
|
||||||
|
}
|
||||||
|
bodyBytes, _ := json.Marshal(payload)
|
||||||
|
client := http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Post(API_BASE+"/api/student/report-violation", "application/json", bytes.NewBuffer(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[VIOLATION] report failed: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
log.Printf("[VIOLATION] report status %d: %s", resp.StatusCode, string(body))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
log.Printf("[VIOLATION] reported kind=%s student=%d", v.Kind, v.StudentRkID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) reportViolation(kind, reason string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
studentID := int64(0)
|
||||||
|
mode := a.dashboard.MonitorMode
|
||||||
|
if a.student != nil {
|
||||||
|
studentID = a.student.StudentID
|
||||||
|
}
|
||||||
|
a.mu.Unlock()
|
||||||
|
if studentID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := pendingViolation{
|
||||||
|
Kind: kind,
|
||||||
|
Reason: reason,
|
||||||
|
MonitorMode: mode,
|
||||||
|
ClientAt: time.Now().Format(time.RFC3339),
|
||||||
|
StudentRkID: studentID,
|
||||||
|
}
|
||||||
|
if !a.postViolation(v) {
|
||||||
|
a.enqueuePendingViolation(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// startLocalServer khởi chạy server lắng nghe callback nhận thông tin sinh viên từ webview
|
// startLocalServer khởi chạy server lắng nghe callback nhận thông tin sinh viên từ webview
|
||||||
func (a *App) startLocalServer() {
|
func (a *App) startLocalServer() {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -392,6 +584,16 @@ func (a *App) startLocalServer() {
|
|||||||
}()
|
}()
|
||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("/exam-clear-cache", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Chỉ purge disk — JS trên trang exam-view tự clear storage + reload iframe.
|
||||||
|
go a.purgeWebViewDiskCache()
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
_ = server.ListenAndServe()
|
_ = server.ListenAndServe()
|
||||||
@@ -408,26 +610,95 @@ const examViewHTML = `<!DOCTYPE html>
|
|||||||
<style>
|
<style>
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
body{font-family:Segoe UI,system-ui,sans-serif;background:#1e293b;color:#f8fafc;height:100vh;display:flex;flex-direction:column}
|
body{font-family:Segoe UI,system-ui,sans-serif;background:#1e293b;color:#f8fafc;height:100vh;display:flex;flex-direction:column}
|
||||||
.bar{display:flex;align-items:center;gap:12px;padding:10px 14px;background:#0f172a;border-bottom:1px solid #334155;flex-shrink:0}
|
.bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#0f172a;border-bottom:1px solid #334155;flex-shrink:0}
|
||||||
.bar button{background:#7c3aed;color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:14px;cursor:pointer;font-weight:600}
|
.bar-title{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:auto;min-width:0}
|
||||||
.bar button:hover{background:#6d28d9}
|
.bar button{border:none;border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer;font-weight:600;white-space:nowrap}
|
||||||
.bar span{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.btn-back{background:#7c3aed;color:#fff}
|
||||||
|
.btn-back:hover{background:#6d28d9}
|
||||||
|
.btn-refresh{background:#0ea5e9;color:#fff}
|
||||||
|
.btn-refresh:hover{background:#0284c7}
|
||||||
|
.btn-cache{background:#f59e0b;color:#111}
|
||||||
|
.btn-cache:hover{background:#d97706}
|
||||||
|
.btn-cache:disabled,.btn-refresh:disabled{opacity:.65;cursor:wait}
|
||||||
|
.hint{font-size:11px;color:#94a3b8;flex-shrink:0}
|
||||||
.frame-wrap{flex:1;min-height:0;background:#fff}
|
.frame-wrap{flex:1;min-height:0;background:#fff}
|
||||||
iframe{width:100%%;height:100%%;border:0;display:block}
|
iframe{width:100%%;height:100%%;border:0;display:block}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
<button type="button" onclick="goBack()">← Quay lại</button>
|
<button type="button" class="btn-back" onclick="goBack()">← Quay lại</button>
|
||||||
<span>%s</span>
|
<span class="bar-title">%s</span>
|
||||||
|
<span class="hint">Nếu trang lỗi / trắng: F5 hoặc Xóa cache</span>
|
||||||
|
<button type="button" class="btn-refresh" id="btn-refresh" onclick="refreshExam()">⟳ F5 Làm mới</button>
|
||||||
|
<button type="button" class="btn-cache" id="btn-cache" onclick="clearExamCache()">🗑 Xóa cache</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="frame-wrap">
|
<div class="frame-wrap">
|
||||||
<iframe src="%s" title="exam-content"></iframe>
|
<iframe id="exam-frame" src="%s" title="exam-content"></iframe>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
function goBack(){
|
function goBack(){
|
||||||
fetch('http://127.0.0.1:34115/exam-close').catch(function(){});
|
fetch('http://127.0.0.1:34115/exam-close').catch(function(){});
|
||||||
}
|
}
|
||||||
|
function refreshExam(){
|
||||||
|
var f = document.getElementById('exam-frame');
|
||||||
|
if (!f) return;
|
||||||
|
var btn = document.getElementById('btn-refresh');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
try {
|
||||||
|
f.contentWindow.location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
var src = f.src;
|
||||||
|
f.src = 'about:blank';
|
||||||
|
setTimeout(function(){ f.src = src; }, 50);
|
||||||
|
}
|
||||||
|
setTimeout(function(){ if (btn) btn.disabled = false; }, 800);
|
||||||
|
}
|
||||||
|
async function clearExamCache(){
|
||||||
|
var btn = document.getElementById('btn-cache');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
try {
|
||||||
|
if (window.caches) {
|
||||||
|
var keys = await caches.keys();
|
||||||
|
await Promise.all(keys.map(function(k){ return caches.delete(k); }));
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
try { localStorage.clear(); } catch (e) {}
|
||||||
|
try { sessionStorage.clear(); } catch (e) {}
|
||||||
|
try {
|
||||||
|
var f = document.getElementById('exam-frame');
|
||||||
|
if (f && f.contentWindow) {
|
||||||
|
try { f.contentWindow.localStorage.clear(); } catch (e) {}
|
||||||
|
try { f.contentWindow.sessionStorage.clear(); } catch (e) {}
|
||||||
|
try {
|
||||||
|
if (f.contentWindow.caches) {
|
||||||
|
var ikeys = await f.contentWindow.caches.keys();
|
||||||
|
await Promise.all(ikeys.map(function(k){ return f.contentWindow.caches.delete(k); }));
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
await fetch('http://127.0.0.1:34115/exam-clear-cache');
|
||||||
|
} catch (e) {}
|
||||||
|
var f = document.getElementById('exam-frame');
|
||||||
|
if (f) {
|
||||||
|
try {
|
||||||
|
var u = new URL(f.src, window.location.href);
|
||||||
|
u.searchParams.set('_sc_cb', String(Date.now()));
|
||||||
|
f.src = u.toString();
|
||||||
|
} catch (e) {
|
||||||
|
refreshExam();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(function(){ if (btn) btn.disabled = false; }, 1200);
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', function(e){
|
||||||
|
if (e.key === 'F5') {
|
||||||
|
e.preventDefault();
|
||||||
|
refreshExam();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
@@ -1017,6 +1288,7 @@ func (a *App) rejectUnauthorizedWifi(ssid, bssid string) {
|
|||||||
a.wifiRejected = true
|
a.wifiRejected = true
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.reportViolation("wifi", fmt.Sprintf("WiFi không được phép: %s (%s)", ssid, bssid))
|
||||||
a.showQuitDialog(
|
a.showQuitDialog(
|
||||||
"WiFi không được phép",
|
"WiFi không được phép",
|
||||||
fmt.Sprintf("Điểm phát WiFi \"%s\" (%s) không được phép.\n\nCó thể là mạng giả mạo (hotspot trùng tên). Hãy kết nối đúng WiFi của trường rồi mở lại ứng dụng.", ssid, bssid),
|
fmt.Sprintf("Điểm phát WiFi \"%s\" (%s) không được phép.\n\nCó thể là mạng giả mạo (hotspot trùng tên). Hãy kết nối đúng WiFi của trường rồi mở lại ứng dụng.", ssid, bssid),
|
||||||
@@ -1132,40 +1404,101 @@ func (a *App) connectWS() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
if a.wsConnected || a.student == nil {
|
if a.wsConnected || a.wsConnecting || a.student == nil {
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.wsConnecting = true
|
||||||
student := a.student
|
student := a.student
|
||||||
classID := a.dashboard.ClassRkID
|
classID := a.dashboard.ClassRkID
|
||||||
if classID <= 0 {
|
if classID <= 0 {
|
||||||
classID = student.SystemID
|
classID = student.SystemID
|
||||||
}
|
}
|
||||||
|
backoff := a.wsBackoff
|
||||||
|
if backoff <= 0 {
|
||||||
|
backoff = 500 * time.Millisecond
|
||||||
|
}
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
wsUrl := fmt.Sprintf("%s/ws?role=student&studentId=%d&classId=%d", getWsUrl(API_BASE), student.StudentID, classID)
|
wsUrl := fmt.Sprintf("%s/ws?role=student&studentId=%d&classId=%d", getWsUrl(API_BASE), student.StudentID, classID)
|
||||||
dialer := websocket.Dialer{HandshakeTimeout: 4 * time.Second}
|
dialer := websocket.Dialer{HandshakeTimeout: 4 * time.Second}
|
||||||
conn, _, err := dialer.Dial(wsUrl, nil)
|
conn, _, err := dialer.Dial(wsUrl, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.wsConnecting = false
|
||||||
|
delay := backoff
|
||||||
|
next := delay * 2
|
||||||
|
if next > 10*time.Second {
|
||||||
|
next = 10 * time.Second
|
||||||
|
}
|
||||||
|
a.wsBackoff = next
|
||||||
|
a.mu.Unlock()
|
||||||
|
log.Printf("[WS] Dial failed: %v — retry in %v", err, delay)
|
||||||
|
time.AfterFunc(delay, func() {
|
||||||
|
if a.isMonitoringActive() && a.CheckLoginStatus() {
|
||||||
|
a.connectWS()
|
||||||
|
}
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(45 * time.Second))
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
return conn.SetReadDeadline(time.Now().Add(45 * time.Second))
|
||||||
|
})
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.wsConn = conn
|
a.wsConn = conn
|
||||||
a.wsConnected = true
|
a.wsConnected = true
|
||||||
|
a.wsConnecting = false
|
||||||
|
a.wsBackoff = 500 * time.Millisecond
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
log.Println("[WS] Connected to proctor websocket hub.")
|
log.Println("[WS] Connected to proctor websocket hub.")
|
||||||
|
a.markRunLock()
|
||||||
|
|
||||||
|
pingDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(15 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-pingDone:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
a.wsWriteMu.Lock()
|
||||||
|
err := conn.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(5*time.Second))
|
||||||
|
a.wsWriteMu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// App-level ping (một số proxy không forward WS control frames đúng).
|
||||||
|
_ = a.safeWriteWS(map[string]any{"event": "client:ping", "data": map[string]any{}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
defer close(pingDone)
|
||||||
defer func() {
|
defer func() {
|
||||||
a.disconnectWS(conn)
|
a.disconnectWS(conn)
|
||||||
go func() {
|
a.mu.Lock()
|
||||||
time.Sleep(500 * time.Millisecond)
|
delay := a.wsBackoff
|
||||||
|
if delay <= 0 {
|
||||||
|
delay = 500 * time.Millisecond
|
||||||
|
}
|
||||||
|
next := delay * 2
|
||||||
|
if next > 10*time.Second {
|
||||||
|
next = 10 * time.Second
|
||||||
|
}
|
||||||
|
a.wsBackoff = next
|
||||||
|
a.mu.Unlock()
|
||||||
|
log.Printf("[WS] Disconnected — reconnect in %v", delay)
|
||||||
|
time.AfterFunc(delay, func() {
|
||||||
if a.isMonitoringActive() && a.CheckLoginStatus() {
|
if a.isMonitoringActive() && a.CheckLoginStatus() {
|
||||||
a.connectWS()
|
a.connectWS()
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
}()
|
}()
|
||||||
for {
|
for {
|
||||||
var msg struct {
|
var msg struct {
|
||||||
@@ -1177,6 +1510,10 @@ func (a *App) connectWS() {
|
|||||||
log.Printf("[WS] ReadJSON error: %v", err)
|
log.Printf("[WS] ReadJSON error: %v", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(45 * time.Second))
|
||||||
|
if msg.Event == "client:pong" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
log.Printf("[WS] Received event: %s", msg.Event)
|
log.Printf("[WS] Received event: %s", msg.Event)
|
||||||
|
|
||||||
switch msg.Event {
|
switch msg.Event {
|
||||||
@@ -1238,6 +1575,7 @@ func (a *App) disconnectWS(expectedConn ...*websocket.Conn) {
|
|||||||
a.wsConn = nil
|
a.wsConn = nil
|
||||||
}
|
}
|
||||||
a.wsConnected = false
|
a.wsConnected = false
|
||||||
|
a.wsConnecting = false
|
||||||
shouldStopStreams = a.isStreamingSc || a.isStreamingCam
|
shouldStopStreams = a.isStreamingSc || a.isStreamingCam
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
@@ -1523,6 +1861,83 @@ func (a *App) ReturnToDashboard() {
|
|||||||
runtime.WindowReloadApp(a.ctx)
|
runtime.WindowReloadApp(a.ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReloadExamPage — F5: làm mới trang / iframe đang mở (trắc nghiệm).
|
||||||
|
func (a *App) ReloadExamPage() {
|
||||||
|
guard.SuppressFor(3 * time.Second)
|
||||||
|
runtime.WindowExecJS(a.ctx, `
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
if (typeof window.refreshExam === 'function') { window.refreshExam(); return; }
|
||||||
|
var f = document.getElementById('exam-frame');
|
||||||
|
if (f) {
|
||||||
|
try { f.contentWindow.location.reload(); }
|
||||||
|
catch (e) { var s = f.src; f.src = 'about:blank'; setTimeout(function(){ f.src = s; }, 50); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
location.reload();
|
||||||
|
})();
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearExamBrowserCache — xóa cache WebView (storage + disk cache) rồi làm mới trang thi.
|
||||||
|
func (a *App) ClearExamBrowserCache() {
|
||||||
|
guard.SuppressFor(8 * time.Second)
|
||||||
|
a.purgeWebViewDiskCache()
|
||||||
|
runtime.WindowExecJS(a.ctx, `
|
||||||
|
(async function(){
|
||||||
|
try {
|
||||||
|
if (window.caches) {
|
||||||
|
var keys = await caches.keys();
|
||||||
|
await Promise.all(keys.map(function(k){ return caches.delete(k); }));
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
try { localStorage.clear(); } catch (e) {}
|
||||||
|
try { sessionStorage.clear(); } catch (e) {}
|
||||||
|
try {
|
||||||
|
var f = document.getElementById('exam-frame');
|
||||||
|
if (f) {
|
||||||
|
try { f.contentWindow.localStorage.clear(); } catch (e) {}
|
||||||
|
try { f.contentWindow.sessionStorage.clear(); } catch (e) {}
|
||||||
|
try {
|
||||||
|
var u = new URL(f.src, window.location.href);
|
||||||
|
u.searchParams.set('_sc_cb', String(Date.now()));
|
||||||
|
f.src = u.toString();
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
try { f.contentWindow.location.reload(); } catch (e2) {
|
||||||
|
var s = f.src; f.src = 'about:blank'; setTimeout(function(){ f.src = s; }, 50);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
location.reload();
|
||||||
|
})();
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) purgeWebViewDiskCache() {
|
||||||
|
if a.webviewDataPath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subs := []string{
|
||||||
|
filepath.Join("EBWebView", "Default", "Cache"),
|
||||||
|
filepath.Join("EBWebView", "Default", "Code Cache"),
|
||||||
|
filepath.Join("EBWebView", "Default", "GPUCache"),
|
||||||
|
filepath.Join("EBWebView", "Default", "Service Worker", "CacheStorage"),
|
||||||
|
filepath.Join("EBWebView", "Default", "Service Worker", "ScriptCache"),
|
||||||
|
}
|
||||||
|
for _, sub := range subs {
|
||||||
|
path := filepath.Join(a.webviewDataPath, sub)
|
||||||
|
if err := os.RemoveAll(path); err != nil {
|
||||||
|
log.Printf("[CACHE] purge %s: %v", path, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[CACHE] purged %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func isLocalExamURL(raw string) bool {
|
func isLocalExamURL(raw string) bool {
|
||||||
u, err := url.Parse(strings.TrimSpace(raw))
|
u, err := url.Parse(strings.TrimSpace(raw))
|
||||||
if err != nil || u.Host == "" {
|
if err != nil || u.Host == "" {
|
||||||
@@ -1533,11 +1948,17 @@ func isLocalExamURL(raw string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) openExamWebView(targetURL, title string) error {
|
func (a *App) openExamWebView(targetURL, title string) error {
|
||||||
|
return a.openExamWebViewOpts(targetURL, title, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openExamWebViewOpts mở nội dung trong WebView.
|
||||||
|
// forceWrap=true luôn dùng khung exam-view (có nút F5 / xóa cache) — dùng cho trắc nghiệm.
|
||||||
|
func (a *App) openExamWebViewOpts(targetURL, title string, forceWrap bool) error {
|
||||||
targetURL = strings.TrimSpace(targetURL)
|
targetURL = strings.TrimSpace(targetURL)
|
||||||
if targetURL == "" {
|
if targetURL == "" {
|
||||||
return errors.New("không có nội dung để mở")
|
return errors.New("không có nội dung để mở")
|
||||||
}
|
}
|
||||||
if isLocalExamURL(targetURL) {
|
if forceWrap || isLocalExamURL(targetURL) {
|
||||||
wrapper := fmt.Sprintf(
|
wrapper := fmt.Sprintf(
|
||||||
"http://127.0.0.1:34115/exam-view?url=%s&title=%s",
|
"http://127.0.0.1:34115/exam-view?url=%s&title=%s",
|
||||||
url.QueryEscape(targetURL),
|
url.QueryEscape(targetURL),
|
||||||
@@ -1547,7 +1968,7 @@ func (a *App) openExamWebView(targetURL, title string) error {
|
|||||||
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper))
|
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Trang thi bên ngoài: mở trực tiếp trong WebView (first-party cookies → giữ phiên đăng nhập).
|
// Trang thi bên ngoài (đề/tài nguyên): mở trực tiếp để giữ first-party cookies.
|
||||||
guard.SuppressFor(5 * time.Second)
|
guard.SuppressFor(5 * time.Second)
|
||||||
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL))
|
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL))
|
||||||
return nil
|
return nil
|
||||||
@@ -1639,7 +2060,8 @@ func (a *App) OpenExamQuiz() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return a.openExamWebView(viewURL, "Làm trắc nghiệm")
|
// Luôn mở trong khung có nút F5 / Xóa cache để SV tự xử lý khi trang lỗi.
|
||||||
|
return a.openExamWebViewOpts(viewURL, "Làm trắc nghiệm", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) OpenExamResource(fileID uint) error {
|
func (a *App) OpenExamResource(fileID uint) error {
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ function renderExamPanel() {
|
|||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div>
|
<div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="exam-panel-desc">Đề PDF và tài nguyên xem ngay trong app. Link trắc nghiệm mở trang thi — xong thì menu <strong>Simple Care → Về trang chính</strong> (Ctrl+H).</p>
|
<p class="exam-panel-desc">Đề PDF và tài nguyên xem trong app. Trắc nghiệm có nút <strong>F5</strong> / <strong>Xóa cache</strong> trên thanh công cụ. Xong: menu <strong>Simple Care → Về trang chính</strong> (Ctrl+H).</p>
|
||||||
<div class="exam-panel-actions">
|
<div class="exam-panel-actions">
|
||||||
${paperBtn}
|
${paperBtn}
|
||||||
${quizBtn}
|
${quizBtn}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
guardOnce sync.Once
|
guardOnce sync.Once
|
||||||
guardViolation func(string)
|
guardViolation func(kind, reason string)
|
||||||
guardStop chan struct{}
|
guardStop chan struct{}
|
||||||
suppressMu sync.Mutex
|
suppressMu sync.Mutex
|
||||||
suppressViolationsUntil time.Time
|
suppressViolationsUntil time.Time
|
||||||
@@ -36,8 +36,8 @@ func violationsSuppressed() bool {
|
|||||||
return time.Now().Before(suppressViolationsUntil)
|
return time.Now().Before(suppressViolationsUntil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start giám sát môi trường macOS — vi phạm thì gọi onViolation (đa màn hình).
|
// Start giám sát môi trường macOS — vi phạm thì gọi onViolation(kind, reason).
|
||||||
func Start(onViolation func(reason string)) {
|
func Start(onViolation func(kind, reason string)) {
|
||||||
guardOnce.Do(func() {
|
guardOnce.Do(func() {
|
||||||
if onViolation == nil {
|
if onViolation == nil {
|
||||||
return
|
return
|
||||||
@@ -46,7 +46,7 @@ func Start(onViolation func(reason string)) {
|
|||||||
guardStop = make(chan struct{})
|
guardStop = make(chan struct{})
|
||||||
|
|
||||||
if screenshot.NumActiveDisplays() > 1 {
|
if screenshot.NumActiveDisplays() > 1 {
|
||||||
onViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
|
onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ func checkEnvironment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n := screenshot.NumActiveDisplays(); n > 1 {
|
if n := screenshot.NumActiveDisplays(); n > 1 {
|
||||||
guardViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
|
guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
guardOnce sync.Once
|
guardOnce sync.Once
|
||||||
guardViolation func(string)
|
guardViolation func(kind, reason string)
|
||||||
guardStop chan struct{}
|
guardStop chan struct{}
|
||||||
suppressMu sync.Mutex
|
suppressMu sync.Mutex
|
||||||
suppressViolationsUntil time.Time
|
suppressViolationsUntil time.Time
|
||||||
@@ -37,7 +37,7 @@ func violationsSuppressed() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start monitors the Linux desktop environment (multi-display check)
|
// Start monitors the Linux desktop environment (multi-display check)
|
||||||
func Start(onViolation func(reason string)) {
|
func Start(onViolation func(kind, reason string)) {
|
||||||
guardOnce.Do(func() {
|
guardOnce.Do(func() {
|
||||||
if onViolation == nil {
|
if onViolation == nil {
|
||||||
return
|
return
|
||||||
@@ -46,7 +46,7 @@ func Start(onViolation func(reason string)) {
|
|||||||
guardStop = make(chan struct{})
|
guardStop = make(chan struct{})
|
||||||
|
|
||||||
if screenshot.NumActiveDisplays() > 1 {
|
if screenshot.NumActiveDisplays() > 1 {
|
||||||
onViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
|
onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ func checkEnvironment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n := screenshot.NumActiveDisplays(); n > 1 {
|
if n := screenshot.NumActiveDisplays(); n > 1 {
|
||||||
guardViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
|
guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ package guard
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
func Start(onViolation func(reason string)) {}
|
func Start(onViolation func(kind, reason string)) {}
|
||||||
func Stop() {}
|
func Stop() {}
|
||||||
func SuppressFor(_ time.Duration) {}
|
func SuppressFor(_ time.Duration) {}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ type msg struct {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
guardOnce sync.Once
|
guardOnce sync.Once
|
||||||
guardViolation func(string)
|
guardViolation func(kind, reason string)
|
||||||
guardStop chan struct{}
|
guardStop chan struct{}
|
||||||
guardBaselineUser string
|
guardBaselineUser string
|
||||||
guardBaselineSession uint32
|
guardBaselineSession uint32
|
||||||
@@ -102,8 +102,8 @@ func violationsSuppressed() bool {
|
|||||||
return time.Now().Before(suppressViolationsUntil)
|
return time.Now().Before(suppressViolationsUntil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start giám sát môi trường Windows — vi phạm thì gọi onViolation (đổi user, đa màn hình, đổi desktop ảo).
|
// Start giám sát môi trường Windows — vi phạm thì gọi onViolation(kind, reason).
|
||||||
func Start(onViolation func(reason string)) {
|
func Start(onViolation func(kind, reason string)) {
|
||||||
guardOnce.Do(func() {
|
guardOnce.Do(func() {
|
||||||
if onViolation == nil {
|
if onViolation == nil {
|
||||||
return
|
return
|
||||||
@@ -114,7 +114,7 @@ func Start(onViolation func(reason string)) {
|
|||||||
guardBaselineSession = currentSessionID()
|
guardBaselineSession = currentSessionID()
|
||||||
|
|
||||||
if monitorCount() > 1 {
|
if monitorCount() > 1 {
|
||||||
onViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
|
onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,27 +147,27 @@ func checkEnvironment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n := monitorCount(); n > 1 {
|
if n := monitorCount(); n > 1 {
|
||||||
guardViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
|
guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user := currentUsername()
|
user := currentUsername()
|
||||||
if user != "" && guardBaselineUser != "" && user != guardBaselineUser {
|
if user != "" && guardBaselineUser != "" && user != guardBaselineUser {
|
||||||
guardViolation("Phát hiện đổi tài khoản Windows. Ứng dụng sẽ thoát.")
|
guardViolation("user_switch", "Phát hiện đổi tài khoản Windows. Ứng dụng sẽ thoát.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sid := currentSessionID()
|
sid := currentSessionID()
|
||||||
if sid != 0 && guardBaselineSession != 0 && sid != guardBaselineSession {
|
if sid != 0 && guardBaselineSession != 0 && sid != guardBaselineSession {
|
||||||
guardViolation("Phiên đăng nhập Windows đã thay đổi. Ứng dụng sẽ thoát.")
|
guardViolation("session_change", "Phiên đăng nhập Windows đã thay đổi. Ứng dụng sẽ thoát.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func triggerViolation(reason string) {
|
func triggerViolation(kind, reason string) {
|
||||||
if violationsSuppressed() {
|
if violationsSuppressed() {
|
||||||
log.Printf("[GUARD] suppressed: %s", reason)
|
log.Printf("[GUARD] suppressed: %s", reason)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if guardViolation != nil {
|
if guardViolation != nil {
|
||||||
guardViolation(reason)
|
guardViolation(kind, reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr {
|
|||||||
case wmWtsSessionChange:
|
case wmWtsSessionChange:
|
||||||
switch uint32(wParam) {
|
switch uint32(wParam) {
|
||||||
case wtsSessionLock, wtsSessionLogoff, wtsConsoleDisconnect, wtsRemoteDisconnect:
|
case wtsSessionLock, wtsSessionLogoff, wtsConsoleDisconnect, wtsRemoteDisconnect:
|
||||||
triggerViolation("Phiên Windows bị khóa, đăng xuất hoặc chuyển người dùng. Ứng dụng sẽ thoát.")
|
triggerViolation("session_change", "Phiên Windows bị khóa, đăng xuất hoặc chuyển người dùng. Ứng dụng sẽ thoát.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
r, _, _ := procDefWindowProcW.Call(hwnd, msg, wParam, lParam)
|
r, _, _ := procDefWindowProcW.Call(hwnd, msg, wParam, lParam)
|
||||||
@@ -271,7 +271,7 @@ func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr {
|
|||||||
|
|
||||||
func desktopSwitchCallback(hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime uintptr) uintptr {
|
func desktopSwitchCallback(hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime uintptr) uintptr {
|
||||||
if event == eventSystemDesktopSwitch {
|
if event == eventSystemDesktopSwitch {
|
||||||
triggerViolation("Không được chuyển Desktop ảo (Win+Tab). Ứng dụng sẽ thoát.")
|
triggerViolation("virtual_desktop", "Không được chuyển Desktop ảo (Win+Tab). Ứng dụng sẽ thoát.")
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -46,6 +47,12 @@ func main() {
|
|||||||
fileMenu.AddText("Về trang chính", keys.CmdOrCtrl("h"), func(_ *menu.CallbackData) {
|
fileMenu.AddText("Về trang chính", keys.CmdOrCtrl("h"), func(_ *menu.CallbackData) {
|
||||||
app.ReturnToDashboard()
|
app.ReturnToDashboard()
|
||||||
})
|
})
|
||||||
|
fileMenu.AddText("Làm mới trang (F5)", keys.Key("f5"), func(_ *menu.CallbackData) {
|
||||||
|
app.ReloadExamPage()
|
||||||
|
})
|
||||||
|
fileMenu.AddText("Xóa cache trình duyệt", keys.CmdOrCtrl("Delete"), func(_ *menu.CallbackData) {
|
||||||
|
app.ClearExamBrowserCache()
|
||||||
|
})
|
||||||
|
|
||||||
// Create application with options
|
// Create application with options
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
@@ -58,6 +65,9 @@ func main() {
|
|||||||
},
|
},
|
||||||
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
||||||
OnStartup: app.startup,
|
OnStartup: app.startup,
|
||||||
|
OnBeforeClose: func(ctx context.Context) (prevent bool) {
|
||||||
|
return app.HandleBeforeClose()
|
||||||
|
},
|
||||||
Windows: &windows.Options{
|
Windows: &windows.Options{
|
||||||
WebviewUserDataPath: app.webviewDataPath,
|
WebviewUserDataPath: app.webviewDataPath,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -381,6 +381,31 @@ export interface StudentSessionLogItem {
|
|||||||
lastActiveAt?: string;
|
lastActiveAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StudentViolationItem {
|
||||||
|
id: number;
|
||||||
|
studentRkId: number;
|
||||||
|
studentCode: string;
|
||||||
|
fullName: string;
|
||||||
|
classRkId: number;
|
||||||
|
kind: string;
|
||||||
|
reason: string;
|
||||||
|
monitorMode: string;
|
||||||
|
clientAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VIOLATION_KIND_OPTIONS = [
|
||||||
|
{ value: '', label: 'Tất cả loại' },
|
||||||
|
{ value: 'app_closed', label: 'Tự đóng app' },
|
||||||
|
{ value: 'unclean_shutdown', label: 'Tắt đột ngột' },
|
||||||
|
{ value: 'multi_monitor', label: 'Nhiều màn hình' },
|
||||||
|
{ value: 'user_switch', label: 'Đổi user' },
|
||||||
|
{ value: 'session_change', label: 'Khóa / đổi phiên' },
|
||||||
|
{ value: 'virtual_desktop', label: 'Desktop ảo' },
|
||||||
|
{ value: 'wifi', label: 'WiFi trái phép' },
|
||||||
|
{ value: 'guard', label: 'Vi phạm môi trường (cũ)' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getStats: async (): Promise<StatsResponse> => {
|
getStats: async (): Promise<StatsResponse> => {
|
||||||
const res = await staffFetch('/stats');
|
const res = await staffFetch('/stats');
|
||||||
@@ -716,6 +741,30 @@ export const apiFetchClassSessionLogs = async (
|
|||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const apiFetchClassViolations = async (
|
||||||
|
rkId: number,
|
||||||
|
date: string,
|
||||||
|
kind = ''
|
||||||
|
): Promise<{ data: StudentViolationItem[]; date: string }> => {
|
||||||
|
const params = new URLSearchParams({ date });
|
||||||
|
if (kind) params.set('kind', kind);
|
||||||
|
const res = await staffFetch(`/classes/${rkId}/violations?${params}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch class violations');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiFetchExamViolations = async (
|
||||||
|
examId: number,
|
||||||
|
date: string,
|
||||||
|
kind = ''
|
||||||
|
): Promise<{ data: StudentViolationItem[]; date: string }> => {
|
||||||
|
const params = new URLSearchParams({ date });
|
||||||
|
if (kind) params.set('kind', kind);
|
||||||
|
const res = await staffFetch(`/exam-rooms/${examId}/violations?${params}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch exam violations');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => {
|
export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => {
|
||||||
const res = await staffFetch(`/classes/${rkId}/online-students`);
|
const res = await staffFetch(`/classes/${rkId}/online-students`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch online students list');
|
if (!res.ok) throw new Error('Failed to fetch online students list');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api';
|
import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api';
|
||||||
import { type StaffChatOpenDetail } from '../chatEvents';
|
import { type StaffChatOpenDetail } from '../chatEvents';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { onStaffChatMessage, playChatSound } from '../hooks/useStaffChatSocket';
|
import { onStaffChatMessage, onStudentViolation, playChatSound, kindLabel } from '../hooks/useStaffChatSocket';
|
||||||
|
|
||||||
export function ChatWidget() {
|
export function ChatWidget() {
|
||||||
const { staff } = useAuth();
|
const { staff } = useAuth();
|
||||||
@@ -83,6 +83,13 @@ export function ChatWidget() {
|
|||||||
return () => { off(); };
|
return () => { off(); };
|
||||||
}, [handleIncoming]);
|
}, [handleIncoming]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return onStudentViolation((v) => {
|
||||||
|
const name = v.studentName || v.studentCode || `SV #${v.studentId}`;
|
||||||
|
showToast(`⚠ ${kindLabel(v.kind)}`, `${name}: ${v.reason || 'Vi phạm giám sát'}`);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onOpen = (e: Event) => {
|
const onOpen = (e: Event) => {
|
||||||
const detail = (e as CustomEvent<StaffChatOpenDetail>).detail;
|
const detail = (e as CustomEvent<StaffChatOpenDetail>).detail;
|
||||||
@@ -152,7 +159,7 @@ export function ChatWidget() {
|
|||||||
const dock = (
|
const dock = (
|
||||||
<div className="chat-dock">
|
<div className="chat-dock">
|
||||||
{toast && (
|
{toast && (
|
||||||
<div className="chat-toast" role="status">
|
<div className={`chat-toast ${toast.title.startsWith('⚠') ? 'chat-toast-alert' : ''}`} role="status">
|
||||||
<strong>{toast.title}</strong>
|
<strong>{toast.title}</strong>
|
||||||
<span>{toast.body}</span>
|
<span>{toast.body}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { AttendancePanel } from './AttendancePanel';
|
|||||||
import { StudentDetailModal } from './StudentDetailModal';
|
import { StudentDetailModal } from './StudentDetailModal';
|
||||||
import { ExamGridProctor } from './ExamGridProctor';
|
import { ExamGridProctor } from './ExamGridProctor';
|
||||||
import { AppPoolModal } from './AppPoolModal';
|
import { AppPoolModal } from './AppPoolModal';
|
||||||
|
import { onStudentPresence } from '../hooks/useStaffChatSocket';
|
||||||
|
import { ViolationsPanel } from './ViolationsPanel';
|
||||||
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
|
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
|
||||||
import { mergeKeywordCSV } from '../utils/appKeywords';
|
import { mergeKeywordCSV } from '../utils/appKeywords';
|
||||||
import { WorkspaceSeatingChart } from './WorkspaceSeatingChart';
|
import { WorkspaceSeatingChart } from './WorkspaceSeatingChart';
|
||||||
@@ -52,7 +54,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [logsLoading, setLogsLoading] = useState(false);
|
const [logsLoading, setLogsLoading] = useState(false);
|
||||||
const [savingApps, setSavingApps] = useState(false);
|
const [savingApps, setSavingApps] = useState(false);
|
||||||
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'grid' | 'logs' | 'attendance'>('roster');
|
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'grid' | 'logs' | 'attendance' | 'violations'>('roster');
|
||||||
const [configOpen, setConfigOpen] = useState(false);
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
|
const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
|
||||||
const [appPoolOpen, setAppPoolOpen] = useState(false);
|
const [appPoolOpen, setAppPoolOpen] = useState(false);
|
||||||
@@ -144,10 +146,21 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
fetchOnlineStatus();
|
fetchOnlineStatus();
|
||||||
fetchLogs(logDate, logPeriod);
|
fetchLogs(logDate, logPeriod);
|
||||||
}, 5000);
|
}, 8000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [classId, logDate, logPeriod]);
|
}, [classId, logDate, logPeriod]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return onStudentPresence(({ studentId, online }) => {
|
||||||
|
setOnlineIds((prev) => {
|
||||||
|
const has = prev.includes(studentId);
|
||||||
|
if (online && !has) return [...prev, studentId];
|
||||||
|
if (!online && has) return prev.filter((id) => id !== studentId);
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLogShifts(logDate);
|
loadLogShifts(logDate);
|
||||||
if (activeSubTab === 'logs') {
|
if (activeSubTab === 'logs') {
|
||||||
@@ -400,6 +413,12 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
>
|
>
|
||||||
Nhật ký theo ca
|
Nhật ký theo ca
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab-sub-btn ${activeSubTab === 'violations' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveSubTab('violations')}
|
||||||
|
>
|
||||||
|
Vi phạm
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`tab-sub-btn ${activeSubTab === 'attendance' ? 'active' : ''}`}
|
className={`tab-sub-btn ${activeSubTab === 'attendance' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveSubTab('attendance')}
|
onClick={() => setActiveSubTab('attendance')}
|
||||||
@@ -410,7 +429,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Student Search */}
|
{/* Student Search */}
|
||||||
{activeSubTab !== 'attendance' && activeSubTab !== 'grid' && <div className="search-input-wrapper">
|
{activeSubTab !== 'attendance' && activeSubTab !== 'grid' && activeSubTab !== 'violations' && <div className="search-input-wrapper">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="search-input"
|
className="search-input"
|
||||||
@@ -425,9 +444,11 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }}></div>
|
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }}></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`workspace-panel-body ${activeSubTab === 'attendance' || activeSubTab === 'logs' || activeSubTab === 'grid' || activeSubTab === 'roster' ? 'workspace-panel-fill' : ''}`}>
|
<div className={`workspace-panel-body ${activeSubTab === 'attendance' || activeSubTab === 'logs' || activeSubTab === 'grid' || activeSubTab === 'roster' || activeSubTab === 'violations' ? 'workspace-panel-fill' : ''}`}>
|
||||||
{activeSubTab === 'attendance' ? (
|
{activeSubTab === 'attendance' ? (
|
||||||
<AttendancePanel classId={classId} />
|
<AttendancePanel classId={classId} />
|
||||||
|
) : activeSubTab === 'violations' ? (
|
||||||
|
<ViolationsPanel mode="class" classId={classId} />
|
||||||
) : activeSubTab === 'grid' ? (
|
) : activeSubTab === 'grid' ? (
|
||||||
<ExamGridProctor
|
<ExamGridProctor
|
||||||
students={students.map((s) => ({
|
students={students.map((s) => ({
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
|||||||
const wsUrl = getWsUrl('/ws?role=teacher');
|
const wsUrl = getWsUrl('/ws?role=teacher');
|
||||||
let closed = false;
|
let closed = false;
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let attempt = 0;
|
||||||
|
|
||||||
|
const clearPing = () => {
|
||||||
|
if (pingTimer) {
|
||||||
|
clearInterval(pingTimer);
|
||||||
|
pingTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
@@ -64,11 +73,21 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
|||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
subscribedRef.current = new Set();
|
subscribedRef.current = new Set();
|
||||||
|
|
||||||
ws.onopen = () => syncSubscriptions(ws);
|
ws.onopen = () => {
|
||||||
|
attempt = 0;
|
||||||
|
syncSubscriptions(ws);
|
||||||
|
clearPing();
|
||||||
|
pingTimer = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ event: 'client:ping', data: {} }));
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.event === 'client:pong') return;
|
||||||
if (msg.event === 'teacher:screenshot-stream-frame') {
|
if (msg.event === 'teacher:screenshot-stream-frame') {
|
||||||
const { studentId, imageBuffer } = msg.data;
|
const { studentId, imageBuffer } = msg.data;
|
||||||
setScreenFrames((prev) => ({ ...prev, [studentId]: imageBuffer }));
|
setScreenFrames((prev) => ({ ...prev, [studentId]: imageBuffer }));
|
||||||
@@ -94,9 +113,11 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
|
clearPing();
|
||||||
subscribedRef.current = new Set();
|
subscribedRef.current = new Set();
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
retryTimer = setTimeout(connect, 3000);
|
const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000);
|
||||||
|
retryTimer = setTimeout(connect, delay);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -105,6 +126,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
closed = true;
|
closed = true;
|
||||||
|
clearPing();
|
||||||
if (retryTimer) clearTimeout(retryTimer);
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
if (ws?.readyState === WebSocket.OPEN) {
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { StudentDetailModal } from './StudentDetailModal';
|
|||||||
import { fmtTime, localInputToISO, toLocalInput } from './ExamsTab';
|
import { fmtTime, localInputToISO, toLocalInput } from './ExamsTab';
|
||||||
import { ExamGridProctor } from './ExamGridProctor';
|
import { ExamGridProctor } from './ExamGridProctor';
|
||||||
import { WorkspaceSeatingChart } from './WorkspaceSeatingChart';
|
import { WorkspaceSeatingChart } from './WorkspaceSeatingChart';
|
||||||
|
import { onStudentPresence } from '../hooks/useStaffChatSocket';
|
||||||
|
import { ViolationsPanel } from './ViolationsPanel';
|
||||||
|
|
||||||
const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])];
|
const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])];
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
const [onlineIds, setOnlineIds] = useState<number[]>([]);
|
const [onlineIds, setOnlineIds] = useState<number[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedStudent, setSelectedStudent] = useState<StudentItem | null>(null);
|
const [selectedStudent, setSelectedStudent] = useState<StudentItem | null>(null);
|
||||||
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions' | 'grid'>('roster');
|
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions' | 'grid' | 'violations'>('roster');
|
||||||
const [configOpen, setConfigOpen] = useState(false);
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info');
|
const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info');
|
||||||
const [papersModalOpen, setPapersModalOpen] = useState(false);
|
const [papersModalOpen, setPapersModalOpen] = useState(false);
|
||||||
@@ -163,10 +165,23 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchOnline();
|
fetchOnline();
|
||||||
const t = setInterval(fetchOnline, 5000);
|
const t = setInterval(fetchOnline, 8000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [fetchOnline]);
|
}, [fetchOnline]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const roster = new Set(students.map((s) => s.studentRkId));
|
||||||
|
return onStudentPresence(({ studentId, online }) => {
|
||||||
|
if (!roster.has(studentId)) return;
|
||||||
|
setOnlineIds((prev) => {
|
||||||
|
const has = prev.includes(studentId);
|
||||||
|
if (online && !has) return [...prev, studentId];
|
||||||
|
if (!online && has) return prev.filter((id) => id !== studentId);
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [students]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!studentPickerOpen || !searchQ.trim()) {
|
if (!studentPickerOpen || !searchQ.trim()) {
|
||||||
if (!studentPickerOpen) setSearchHits([]);
|
if (!studentPickerOpen) setSearchHits([]);
|
||||||
@@ -448,6 +463,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
|
if (!quizUrl.trim()) {
|
||||||
|
const ok = confirm(
|
||||||
|
'Phòng thi chưa có link trắc nghiệm.\n\nBạn có chắc chắn muốn đẩy phòng thi không có phần thi trắc nghiệm không?'
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
setErr(''); setMsg('');
|
setErr(''); setMsg('');
|
||||||
try {
|
try {
|
||||||
await apiExam.publish(examId);
|
await apiExam.publish(examId);
|
||||||
@@ -880,11 +901,18 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
>
|
>
|
||||||
Bài nộp ({submissions.length})
|
Bài nộp ({submissions.length})
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tab-sub-btn ${activeSubTab === 'violations' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveSubTab('violations')}
|
||||||
|
>
|
||||||
|
Vi phạm
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
{activeSubTab !== 'submissions' && activeSubTab !== 'grid' && (
|
{activeSubTab !== 'submissions' && activeSubTab !== 'grid' && activeSubTab !== 'violations' && (
|
||||||
<div className="search-input-wrapper">
|
<div className="search-input-wrapper">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1014,6 +1042,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : activeSubTab === 'violations' ? (
|
||||||
|
<ViolationsPanel mode="exam" examId={examId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="session-logs-panel">
|
<div className="session-logs-panel">
|
||||||
{submissions.length > 0 && (
|
{submissions.length > 0 && (
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wsUrl = getWsUrl('/ws?role=teacher');
|
const wsUrl = getWsUrl('/ws?role=teacher');
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let attempt = 0;
|
||||||
|
|
||||||
intentionalClose.current = false;
|
intentionalClose.current = false;
|
||||||
hasOpened.current = false;
|
hasOpened.current = false;
|
||||||
@@ -50,6 +52,13 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearPing = () => {
|
||||||
|
if (pingTimer) {
|
||||||
|
clearInterval(pingTimer);
|
||||||
|
pingTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (intentionalClose.current) return;
|
if (intentionalClose.current) return;
|
||||||
|
|
||||||
@@ -57,15 +66,23 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
attempt = 0;
|
||||||
hasOpened.current = true;
|
hasOpened.current = true;
|
||||||
setStreaming(true);
|
setStreaming(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } }));
|
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } }));
|
||||||
|
clearPing();
|
||||||
|
pingTimer = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ event: 'client:ping', data: {} }));
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.event === 'client:pong') return;
|
||||||
if (msg.event === 'teacher:screenshot-stream-frame' && msg.data.studentId === studentId) {
|
if (msg.event === 'teacher:screenshot-stream-frame' && msg.data.studentId === studentId) {
|
||||||
setScreenFrame(msg.data.imageBuffer);
|
setScreenFrame(msg.data.imageBuffer);
|
||||||
markStreaming();
|
markStreaming();
|
||||||
@@ -86,6 +103,7 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
ws.onerror = () => {};
|
ws.onerror = () => {};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
|
clearPing();
|
||||||
if (intentionalClose.current) return;
|
if (intentionalClose.current) return;
|
||||||
setStreaming(false);
|
setStreaming(false);
|
||||||
if (!hasOpened.current && !hasFrames.current) {
|
if (!hasOpened.current && !hasFrames.current) {
|
||||||
@@ -93,7 +111,8 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
setErrorMessage('Mất kết nối giám sát — đang thử lại...');
|
setErrorMessage('Mất kết nối giám sát — đang thử lại...');
|
||||||
}
|
}
|
||||||
retryTimer = setTimeout(connect, 3000);
|
const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000);
|
||||||
|
retryTimer = setTimeout(connect, delay);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,6 +120,7 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
intentionalClose.current = true;
|
intentionalClose.current = true;
|
||||||
|
clearPing();
|
||||||
if (retryTimer) clearTimeout(retryTimer);
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
wsRef.current.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } }));
|
wsRef.current.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } }));
|
||||||
|
|||||||
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 { useCallback, useEffect, useRef } from 'react';
|
||||||
import { type ChatMessage, getWsUrl } from '../api';
|
import { type ChatMessage, getWsUrl } from '../api';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { playChatSound } from '../utils/notifySound';
|
import { playChatSound, playAlertSound } from '../utils/notifySound';
|
||||||
|
|
||||||
type ChatIncomingHandler = (msg: ChatMessage) => void;
|
type ChatIncomingHandler = (msg: ChatMessage) => void;
|
||||||
|
export type PresenceUpdate = { studentId: number; online: boolean; classId?: number };
|
||||||
|
export type StudentViolationEvent = {
|
||||||
|
studentId: number;
|
||||||
|
studentName?: string;
|
||||||
|
studentCode?: string;
|
||||||
|
kind: string;
|
||||||
|
reason: string;
|
||||||
|
monitorMode?: string;
|
||||||
|
classId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
const handlers = new Set<ChatIncomingHandler>();
|
const chatHandlers = new Set<ChatIncomingHandler>();
|
||||||
|
const presenceHandlers = new Set<(p: PresenceUpdate) => void>();
|
||||||
|
const violationHandlers = new Set<(v: StudentViolationEvent) => void>();
|
||||||
|
|
||||||
export function onStaffChatMessage(handler: ChatIncomingHandler): () => void {
|
export function onStaffChatMessage(handler: ChatIncomingHandler): () => void {
|
||||||
handlers.add(handler);
|
chatHandlers.add(handler);
|
||||||
return () => { handlers.delete(handler); };
|
return () => { chatHandlers.delete(handler); };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** WebSocket luôn bật khi đã login — nhận tin sinh viên realtime */
|
export function onStudentPresence(handler: (p: PresenceUpdate) => void): () => void {
|
||||||
|
presenceHandlers.add(handler);
|
||||||
|
return () => { presenceHandlers.delete(handler); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onStudentViolation(handler: (v: StudentViolationEvent) => void): () => void {
|
||||||
|
violationHandlers.add(handler);
|
||||||
|
return () => { violationHandlers.delete(handler); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBackoff(attempt: number): number {
|
||||||
|
const base = Math.min(1000 * 2 ** Math.min(attempt, 4), 10000);
|
||||||
|
return base + Math.floor(Math.random() * 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kindLabel(kind: string): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'app_closed': return 'Tự đóng app';
|
||||||
|
case 'unclean_shutdown': return 'Tắt đột ngột';
|
||||||
|
case 'multi_monitor': return 'Nhiều màn hình';
|
||||||
|
case 'user_switch': return 'Đổi user';
|
||||||
|
case 'session_change': return 'Khóa / đổi phiên';
|
||||||
|
case 'virtual_desktop': return 'Desktop ảo';
|
||||||
|
case 'wifi': return 'WiFi trái phép';
|
||||||
|
case 'guard': return 'Vi phạm môi trường';
|
||||||
|
default: return kind || 'Vi phạm';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WebSocket luôn bật khi đã login — chat + presence + violation + keepalive. */
|
||||||
export function StaffChatSocket() {
|
export function StaffChatSocket() {
|
||||||
const { staff, token } = useAuth();
|
const { staff, token } = useAuth();
|
||||||
const staffIdRef = useRef(0);
|
const staffIdRef = useRef(0);
|
||||||
@@ -21,8 +62,8 @@ export function StaffChatSocket() {
|
|||||||
staffIdRef.current = staff?.id ?? 0;
|
staffIdRef.current = staff?.id ?? 0;
|
||||||
}, [staff?.id]);
|
}, [staff?.id]);
|
||||||
|
|
||||||
const dispatch = useCallback((msg: ChatMessage) => {
|
const dispatchChat = useCallback((msg: ChatMessage) => {
|
||||||
handlers.forEach((h) => h(msg));
|
chatHandlers.forEach((h) => h(msg));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,26 +72,74 @@ export function StaffChatSocket() {
|
|||||||
const wsUrl = getWsUrl(`/ws?role=teacher&staffId=${staff.id}`);
|
const wsUrl = getWsUrl(`/ws?role=teacher&staffId=${staff.id}`);
|
||||||
let ws: WebSocket | null = null;
|
let ws: WebSocket | null = null;
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
|
let attempt = 0;
|
||||||
|
|
||||||
|
const clearPing = () => {
|
||||||
|
if (pingTimer) {
|
||||||
|
clearInterval(pingTimer);
|
||||||
|
pingTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
ws = new WebSocket(wsUrl);
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
attempt = 0;
|
||||||
|
clearPing();
|
||||||
|
pingTimer = setInterval(() => {
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ event: 'client:ping', data: {} }));
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
};
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(ev.data);
|
const payload = JSON.parse(ev.data);
|
||||||
|
if (payload.event === 'client:pong') return;
|
||||||
|
if (payload.event === 'presence:update') {
|
||||||
|
const studentId = Number(payload.data?.studentId ?? 0);
|
||||||
|
if (!studentId) return;
|
||||||
|
presenceHandlers.forEach((h) => h({
|
||||||
|
studentId,
|
||||||
|
online: !!payload.data?.online,
|
||||||
|
classId: Number(payload.data?.classId ?? 0) || undefined,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.event === 'teacher:student-violation') {
|
||||||
|
const studentId = Number(payload.data?.studentId ?? 0);
|
||||||
|
if (!studentId) return;
|
||||||
|
playAlertSound();
|
||||||
|
violationHandlers.forEach((h) => h({
|
||||||
|
studentId,
|
||||||
|
studentName: payload.data?.studentName || '',
|
||||||
|
studentCode: payload.data?.studentCode || '',
|
||||||
|
kind: String(payload.data?.kind || ''),
|
||||||
|
reason: String(payload.data?.reason || ''),
|
||||||
|
monitorMode: payload.data?.monitorMode || '',
|
||||||
|
classId: Number(payload.data?.classId ?? 0) || undefined,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (payload.event !== 'chat:message') return;
|
if (payload.event !== 'chat:message') return;
|
||||||
const msg = payload.data as ChatMessage;
|
const msg = payload.data as ChatMessage;
|
||||||
const targetStaff = Number(msg.targetStaffId ?? msg.staffId ?? 0);
|
const targetStaff = Number(msg.targetStaffId ?? msg.staffId ?? 0);
|
||||||
if (targetStaff > 0 && targetStaff !== staffIdRef.current) return;
|
if (targetStaff > 0 && targetStaff !== staffIdRef.current) return;
|
||||||
dispatch(msg);
|
dispatchChat(msg);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
|
clearPing();
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
retryTimer = setTimeout(connect, 3000);
|
retryTimer = setTimeout(connect, scheduleBackoff(attempt++));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -59,12 +148,13 @@ export function StaffChatSocket() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
closed = true;
|
closed = true;
|
||||||
|
clearPing();
|
||||||
if (retryTimer) clearTimeout(retryTimer);
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
ws?.close();
|
ws?.close();
|
||||||
};
|
};
|
||||||
}, [token, staff?.id, dispatch]);
|
}, [token, staff?.id, dispatchChat]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { playChatSound };
|
export { playChatSound, playAlertSound };
|
||||||
|
|||||||
@@ -3874,6 +3874,15 @@ input:checked + .slider:before {
|
|||||||
color: #93c5fd;
|
color: #93c5fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-toast-alert {
|
||||||
|
background: #7f1d1d;
|
||||||
|
border: 1px solid #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-toast-alert strong {
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes chat-toast-in {
|
@keyframes chat-toast-in {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
|||||||
@@ -35,3 +35,29 @@ export function playChatSound() {
|
|||||||
playTone(880, t, 0.12);
|
playTone(880, t, 0.12);
|
||||||
playTone(1174, t + 0.14, 0.14);
|
playTone(1174, t + 0.14, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Louder alert for student violations (quit / kill app) */
|
||||||
|
export function playAlertSound() {
|
||||||
|
const ctx = getAudioCtx();
|
||||||
|
if (!ctx) return;
|
||||||
|
if (ctx.state === 'suspended') {
|
||||||
|
void ctx.resume();
|
||||||
|
}
|
||||||
|
const playTone = (freq: number, start: number, duration: number) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = 'square';
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
gain.gain.setValueAtTime(0.0001, start);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.1, start + 0.02);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start(start);
|
||||||
|
osc.stop(start + duration + 0.02);
|
||||||
|
};
|
||||||
|
const t = ctx.currentTime;
|
||||||
|
playTone(520, t, 0.16);
|
||||||
|
playTone(390, t + 0.18, 0.2);
|
||||||
|
playTone(520, t + 0.4, 0.18);
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.WifiPoolEntry{},
|
&models.WifiPoolEntry{},
|
||||||
&models.AcceptedWifi{},
|
&models.AcceptedWifi{},
|
||||||
&models.StudentSession{},
|
&models.StudentSession{},
|
||||||
|
&models.StudentViolation{},
|
||||||
&models.AttendanceResult{},
|
&models.AttendanceResult{},
|
||||||
&models.StaffAccount{},
|
&models.StaffAccount{},
|
||||||
&models.EmailDomain{},
|
&models.EmailDomain{},
|
||||||
|
|||||||
@@ -735,6 +735,227 @@ func ReportBlockedAppHandler(db *gorm.DB) fiber.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/student/report-violation — SV tắt app / vi phạm khi đang giám sát
|
||||||
|
func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
StudentRkID int64 `json:"studentRkId"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
MonitorMode string `json:"monitorMode"`
|
||||||
|
ClientAt string `json:"clientAt"` // RFC3339 optional
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
|
||||||
|
}
|
||||||
|
if req.StudentRkID <= 0 || strings.TrimSpace(req.Kind) == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId and kind are required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
kind := strings.ToLower(strings.TrimSpace(req.Kind))
|
||||||
|
reason := strings.TrimSpace(req.Reason)
|
||||||
|
if reason == "" {
|
||||||
|
reason = kind
|
||||||
|
}
|
||||||
|
classID := internalDb.FindActiveClassForStudent(db, req.StudentRkID)
|
||||||
|
clientAt := time.Now()
|
||||||
|
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.ClientAt)); err == nil {
|
||||||
|
clientAt = t
|
||||||
|
}
|
||||||
|
|
||||||
|
row := models.StudentViolation{
|
||||||
|
StudentRkID: req.StudentRkID,
|
||||||
|
ClassRkID: classID,
|
||||||
|
Kind: kind,
|
||||||
|
Reason: reason,
|
||||||
|
MonitorMode: strings.TrimSpace(req.MonitorMode),
|
||||||
|
ClientAt: clientAt,
|
||||||
|
}
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
studentName := ""
|
||||||
|
studentCode := ""
|
||||||
|
var st models.Student
|
||||||
|
if err := db.Where("rk_id = ?", req.StudentRkID).First(&st).Error; err == nil {
|
||||||
|
studentName = st.FullName
|
||||||
|
studentCode = st.StudentCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cố ý tắt / crash / môi trường → offline ngay, không dùng grace 8s
|
||||||
|
switch kind {
|
||||||
|
case "app_closed", "unclean_shutdown", "wifi",
|
||||||
|
"guard", "multi_monitor", "user_switch", "session_change", "virtual_desktop":
|
||||||
|
internalWs.Hub.ForceStudentOffline(req.StudentRkID)
|
||||||
|
}
|
||||||
|
|
||||||
|
internalWs.Hub.BroadcastStudentViolation(map[string]any{
|
||||||
|
"id": row.ID,
|
||||||
|
"studentId": req.StudentRkID,
|
||||||
|
"studentName": studentName,
|
||||||
|
"studentCode": studentCode,
|
||||||
|
"classId": classID,
|
||||||
|
"kind": kind,
|
||||||
|
"reason": reason,
|
||||||
|
"monitorMode": row.MonitorMode,
|
||||||
|
"clientAt": clientAt.Format(time.RFC3339),
|
||||||
|
"createdAt": row.CreatedAt.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "id": row.ID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type studentViolationItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
StudentRkID int64 `json:"studentRkId"`
|
||||||
|
StudentCode string `json:"studentCode"`
|
||||||
|
FullName string `json:"fullName"`
|
||||||
|
ClassRkID int64 `json:"classRkId"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
MonitorMode string `json:"monitorMode"`
|
||||||
|
ClientAt time.Time `json:"clientAt"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/classes/:rkId/violations?date=YYYY-MM-DD&kind=
|
||||||
|
func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
|
||||||
|
}
|
||||||
|
date := c.Query("date")
|
||||||
|
if date == "" {
|
||||||
|
date = time.Now().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
kindFilter := strings.TrimSpace(c.Query("kind", ""))
|
||||||
|
|
||||||
|
var mappings []models.ClassStudent
|
||||||
|
if err := db.Where("class_rk_id = ?", classRkID).Find(&mappings).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
if len(mappings) == 0 {
|
||||||
|
return c.JSON(fiber.Map{"data": []any{}})
|
||||||
|
}
|
||||||
|
studentIDs := make([]int64, 0, len(mappings))
|
||||||
|
for _, m := range mappings {
|
||||||
|
studentIDs = append(studentIDs, m.StudentRkID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var students []models.Student
|
||||||
|
if err := db.Where("rk_id IN ?", studentIDs).Find(&students).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
nameByID := map[int64]models.Student{}
|
||||||
|
for _, st := range students {
|
||||||
|
nameByID[st.RkID] = st
|
||||||
|
}
|
||||||
|
|
||||||
|
dayStart, err := time.ParseInLocation("2006-01-02", date, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"})
|
||||||
|
}
|
||||||
|
dayEnd := dayStart.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
q := db.Where("student_rk_id IN ? AND created_at >= ? AND created_at < ?", studentIDs, dayStart, dayEnd)
|
||||||
|
if kindFilter != "" {
|
||||||
|
q = q.Where("kind = ?", kindFilter)
|
||||||
|
}
|
||||||
|
var rows []models.StudentViolation
|
||||||
|
if err := q.Order("created_at desc").Limit(500).Find(&rows).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]studentViolationItem, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
st := nameByID[r.StudentRkID]
|
||||||
|
out = append(out, studentViolationItem{
|
||||||
|
ID: r.ID,
|
||||||
|
StudentRkID: r.StudentRkID,
|
||||||
|
StudentCode: st.StudentCode,
|
||||||
|
FullName: st.FullName,
|
||||||
|
ClassRkID: r.ClassRkID,
|
||||||
|
Kind: r.Kind,
|
||||||
|
Reason: r.Reason,
|
||||||
|
MonitorMode: r.MonitorMode,
|
||||||
|
ClientAt: r.ClientAt,
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"data": out, "date": date})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/exam-rooms/:id/violations?date=YYYY-MM-DD
|
||||||
|
func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid exam room id"})
|
||||||
|
}
|
||||||
|
date := c.Query("date")
|
||||||
|
if date == "" {
|
||||||
|
date = time.Now().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
kindFilter := strings.TrimSpace(c.Query("kind", ""))
|
||||||
|
|
||||||
|
var roster []models.ExamRoomStudent
|
||||||
|
if err := db.Where("exam_room_id = ?", id).Find(&roster).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
if len(roster) == 0 {
|
||||||
|
return c.JSON(fiber.Map{"data": []any{}})
|
||||||
|
}
|
||||||
|
studentIDs := make([]int64, 0, len(roster))
|
||||||
|
for _, s := range roster {
|
||||||
|
studentIDs = append(studentIDs, s.StudentRkID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var students []models.Student
|
||||||
|
_ = db.Where("rk_id IN ?", studentIDs).Find(&students)
|
||||||
|
nameByID := map[int64]models.Student{}
|
||||||
|
for _, st := range students {
|
||||||
|
nameByID[st.RkID] = st
|
||||||
|
}
|
||||||
|
|
||||||
|
dayStart, err := time.ParseInLocation("2006-01-02", date, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"})
|
||||||
|
}
|
||||||
|
dayEnd := dayStart.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
q := db.Where("student_rk_id IN ? AND created_at >= ? AND created_at < ?", studentIDs, dayStart, dayEnd)
|
||||||
|
if kindFilter != "" {
|
||||||
|
q = q.Where("kind = ?", kindFilter)
|
||||||
|
}
|
||||||
|
var rows []models.StudentViolation
|
||||||
|
if err := q.Order("created_at desc").Limit(500).Find(&rows).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]studentViolationItem, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
st := nameByID[r.StudentRkID]
|
||||||
|
out = append(out, studentViolationItem{
|
||||||
|
ID: r.ID,
|
||||||
|
StudentRkID: r.StudentRkID,
|
||||||
|
StudentCode: st.StudentCode,
|
||||||
|
FullName: st.FullName,
|
||||||
|
ClassRkID: r.ClassRkID,
|
||||||
|
Kind: r.Kind,
|
||||||
|
Reason: r.Reason,
|
||||||
|
MonitorMode: r.MonitorMode,
|
||||||
|
ClientAt: r.ClientAt,
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"data": out, "date": date})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/app-pool — kho app bị chặn toàn hệ thống (?q= tìm kiếm, limit= mặc định 50)
|
// GET /api/app-pool — kho app bị chặn toàn hệ thống (?q= tìm kiếm, limit= mặc định 50)
|
||||||
func ListAppPoolHandler(db *gorm.DB) fiber.Handler {
|
func ListAppPoolHandler(db *gorm.DB) fiber.Handler {
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
|
|||||||
@@ -179,6 +179,20 @@ type StudentSession struct {
|
|||||||
|
|
||||||
func (StudentSession) TableName() string { return "student_sessions" }
|
func (StudentSession) TableName() string { return "student_sessions" }
|
||||||
|
|
||||||
|
// StudentViolation — ghi nhận SV tắt app / vi phạm môi trường khi đang giám sát
|
||||||
|
type StudentViolation struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"`
|
||||||
|
ClassRkID int64 `gorm:"column:class_rk_id;index" json:"classRkId"`
|
||||||
|
Kind string `gorm:"column:kind;size:64;not null;index" json:"kind"` // app_closed | unclean_shutdown | multi_monitor | user_switch | session_change | virtual_desktop | wifi | guard
|
||||||
|
Reason string `gorm:"column:reason;type:text" json:"reason"`
|
||||||
|
MonitorMode string `gorm:"column:monitor_mode;size:32" json:"monitorMode"`
|
||||||
|
ClientAt time.Time `gorm:"column:client_at" json:"clientAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (StudentViolation) TableName() string { return "student_violations" }
|
||||||
|
|
||||||
// SystemSetting lưu các cấu hình hệ thống động
|
// SystemSetting lưu các cấu hình hệ thống động
|
||||||
type SystemSetting struct {
|
type SystemSetting struct {
|
||||||
SettingKey string `gorm:"primaryKey;column:setting_key;size:128" json:"settingKey"`
|
SettingKey string `gorm:"primaryKey;column:setting_key;size:128" json:"settingKey"`
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
wsPingInterval = 30 * time.Second
|
// Heartbeat kiểu game: phát hiện mất kết nối trong ~30–45s.
|
||||||
wsPongWait = 60 * time.Second
|
wsPingInterval = 15 * time.Second
|
||||||
|
wsPongWait = 45 * time.Second
|
||||||
|
// Grace khi SV reconnect — tránh nhấp nháy online/offline.
|
||||||
|
studentOfflineGrace = 8 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type SocketMsg struct {
|
type SocketMsg struct {
|
||||||
@@ -44,6 +47,10 @@ func (c *SocketClient) WriteRaw(msg []byte) error {
|
|||||||
return c.Conn.WriteMessage(websocket.TextMessage, msg)
|
return c.Conn.WriteMessage(websocket.TextMessage, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type offlineGrace struct {
|
||||||
|
until time.Time
|
||||||
|
classID int64
|
||||||
|
}
|
||||||
|
|
||||||
type WsHub struct {
|
type WsHub struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -51,6 +58,8 @@ type WsHub struct {
|
|||||||
teachers map[string]*SocketClient
|
teachers map[string]*SocketClient
|
||||||
teachersByStaff map[uint][]string // staffId -> teacher connection addresses
|
teachersByStaff map[uint][]string // staffId -> teacher connection addresses
|
||||||
subscribers map[int64][]string // studentId -> list of teacher connection addresses
|
subscribers map[int64][]string // studentId -> list of teacher connection addresses
|
||||||
|
grace map[int64]offlineGrace
|
||||||
|
graceTimers map[int64]*time.Timer
|
||||||
}
|
}
|
||||||
|
|
||||||
var Hub = &WsHub{
|
var Hub = &WsHub{
|
||||||
@@ -58,13 +67,18 @@ var Hub = &WsHub{
|
|||||||
teachers: make(map[string]*SocketClient),
|
teachers: make(map[string]*SocketClient),
|
||||||
teachersByStaff: make(map[uint][]string),
|
teachersByStaff: make(map[uint][]string),
|
||||||
subscribers: make(map[int64][]string),
|
subscribers: make(map[int64][]string),
|
||||||
|
grace: make(map[int64]offlineGrace),
|
||||||
|
graceTimers: make(map[int64]*time.Timer),
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
|
func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
_, ok := h.students[studentRkID]
|
if _, ok := h.students[studentRkID]; ok {
|
||||||
return ok
|
return true
|
||||||
|
}
|
||||||
|
g, ok := h.grace[studentRkID]
|
||||||
|
return ok && time.Now().Before(g.until)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WsHub) PushExamToStudent(studentRkID int64, data map[string]any) {
|
func (h *WsHub) PushExamToStudent(studentRkID int64, data map[string]any) {
|
||||||
@@ -102,16 +116,126 @@ func (h *WsHub) PushChatToStaff(staffID uint, data map[string]any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WsHub) BroadcastStudentViolation(data map[string]any) {
|
||||||
|
msg := SocketMsg{Event: "teacher:student-violation", Data: data}
|
||||||
|
msgBytes, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for _, t := range h.teachers {
|
||||||
|
if t != nil {
|
||||||
|
_ = t.WriteRaw(msgBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WsHub) broadcastPresence(studentID int64, online bool, classID int64) {
|
||||||
|
msg := SocketMsg{
|
||||||
|
Event: "presence:update",
|
||||||
|
Data: map[string]any{
|
||||||
|
"studentId": studentID,
|
||||||
|
"online": online,
|
||||||
|
"classId": classID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBytes, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, t := range h.teachers {
|
||||||
|
if t != nil {
|
||||||
|
_ = t.WriteRaw(msgBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceStudentOffline — tắt ngay (không chờ grace), dùng khi SV cố ý đóng app / vi phạm.
|
||||||
|
func (h *WsHub) ForceStudentOffline(studentID int64) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
classID := int64(0)
|
||||||
|
if client, ok := h.students[studentID]; ok && client != nil {
|
||||||
|
classID = client.ClassID
|
||||||
|
delete(h.students, studentID)
|
||||||
|
}
|
||||||
|
if g, ok := h.grace[studentID]; ok {
|
||||||
|
if classID == 0 {
|
||||||
|
classID = g.classID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.cancelGraceLocked(studentID)
|
||||||
|
|
||||||
|
log.Printf("[WS] Student %d force offline (intentional quit/violation)", studentID)
|
||||||
|
h.broadcastPresence(studentID, false, classID)
|
||||||
|
if teachers, exists := h.subscribers[studentID]; exists {
|
||||||
|
for _, tAddr := range teachers {
|
||||||
|
if t, found := h.teachers[tAddr]; found {
|
||||||
|
_ = t.WriteJSON(SocketMsg{
|
||||||
|
Event: "teacher:stream-stopped",
|
||||||
|
Data: map[string]any{"studentId": studentID},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WsHub) cancelGraceLocked(studentID int64) {
|
||||||
|
if t, ok := h.graceTimers[studentID]; ok {
|
||||||
|
t.Stop()
|
||||||
|
delete(h.graceTimers, studentID)
|
||||||
|
}
|
||||||
|
delete(h.grace, studentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WsHub) finalizeStudentOffline(studentID int64, classID int64) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
if _, online := h.students[studentID]; online {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := h.grace[studentID]; !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(h.grace, studentID)
|
||||||
|
delete(h.graceTimers, studentID)
|
||||||
|
|
||||||
|
log.Printf("[WS] Student %d offline after grace", studentID)
|
||||||
|
h.broadcastPresence(studentID, false, classID)
|
||||||
|
|
||||||
|
if teachers, exists := h.subscribers[studentID]; exists {
|
||||||
|
for _, tAddr := range teachers {
|
||||||
|
if t, found := h.teachers[tAddr]; found {
|
||||||
|
_ = t.WriteJSON(SocketMsg{
|
||||||
|
Event: "teacher:stream-stopped",
|
||||||
|
Data: map[string]any{"studentId": studentID},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WsHub) GetOnlineStudentIDs(classID int64) []int64 {
|
func (h *WsHub) GetOnlineStudentIDs(classID int64) []int64 {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
seen := map[int64]bool{}
|
||||||
var ids []int64
|
var ids []int64
|
||||||
|
now := time.Now()
|
||||||
for _, client := range h.students {
|
for _, client := range h.students {
|
||||||
if client.ClassID == classID {
|
if client.ClassID == classID {
|
||||||
|
seen[client.StudentID] = true
|
||||||
ids = append(ids, client.StudentID)
|
ids = append(ids, client.StudentID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for sid, g := range h.grace {
|
||||||
|
if now.Before(g.until) && g.classID == classID && !seen[sid] {
|
||||||
|
ids = append(ids, sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
if ids == nil {
|
if ids == nil {
|
||||||
return []int64{}
|
return []int64{}
|
||||||
}
|
}
|
||||||
@@ -124,8 +248,14 @@ func (h *WsHub) Register(c *SocketClient) {
|
|||||||
|
|
||||||
c.Addr = c.Conn.RemoteAddr().String()
|
c.Addr = c.Conn.RemoteAddr().String()
|
||||||
if c.Role == "student" {
|
if c.Role == "student" {
|
||||||
|
wasInGrace := false
|
||||||
|
if _, ok := h.grace[c.StudentID]; ok {
|
||||||
|
wasInGrace = true
|
||||||
|
}
|
||||||
|
h.cancelGraceLocked(c.StudentID)
|
||||||
h.students[c.StudentID] = c
|
h.students[c.StudentID] = c
|
||||||
log.Printf("[WS] Student %d registered (Address: %s, Class: %d)", c.StudentID, c.Addr, c.ClassID)
|
log.Printf("[WS] Student %d registered (Address: %s, Class: %d, reconnectGrace=%v)", c.StudentID, c.Addr, c.ClassID, wasInGrace)
|
||||||
|
h.broadcastPresence(c.StudentID, true, c.ClassID)
|
||||||
if subs, exists := h.subscribers[c.StudentID]; exists && len(subs) > 0 {
|
if subs, exists := h.subscribers[c.StudentID]; exists && len(subs) > 0 {
|
||||||
_ = c.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
_ = c.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
||||||
_ = c.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
_ = c.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
||||||
@@ -151,19 +281,17 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
delete(h.students, c.StudentID)
|
delete(h.students, c.StudentID)
|
||||||
log.Printf("[WS] Student %d disconnected", c.StudentID)
|
classID := c.ClassID
|
||||||
|
studentID := c.StudentID
|
||||||
|
|
||||||
// Báo cho các giáo viên đang xem là stream của học sinh đã dừng
|
h.cancelGraceLocked(studentID)
|
||||||
if teachers, exists := h.subscribers[c.StudentID]; exists {
|
deadline := time.Now().Add(studentOfflineGrace)
|
||||||
for _, tAddr := range teachers {
|
h.grace[studentID] = offlineGrace{until: deadline, classID: classID}
|
||||||
if t, found := h.teachers[tAddr]; found {
|
h.graceTimers[studentID] = time.AfterFunc(studentOfflineGrace, func() {
|
||||||
_ = t.WriteJSON(SocketMsg{
|
h.finalizeStudentOffline(studentID, classID)
|
||||||
Event: "teacher:stream-stopped",
|
|
||||||
Data: map[string]any{"studentId": c.StudentID},
|
|
||||||
})
|
})
|
||||||
}
|
log.Printf("[WS] Student %d disconnected — grace %v before offline", studentID, studentOfflineGrace)
|
||||||
}
|
// Chưa broadcast offline / stream-stopped — chờ grace (reconnect nhanh như game).
|
||||||
}
|
|
||||||
} else if c.Role == "teacher" {
|
} else if c.Role == "teacher" {
|
||||||
delete(h.teachers, c.Addr)
|
delete(h.teachers, c.Addr)
|
||||||
if c.StaffID > 0 {
|
if c.StaffID > 0 {
|
||||||
@@ -182,7 +310,6 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
|||||||
}
|
}
|
||||||
log.Printf("[WS] Teacher %s disconnected", c.Addr)
|
log.Printf("[WS] Teacher %s disconnected", c.Addr)
|
||||||
|
|
||||||
// Dọn dẹp subscriptions của giáo viên này
|
|
||||||
for sID, teachersList := range h.subscribers {
|
for sID, teachersList := range h.subscribers {
|
||||||
newList := []string{}
|
newList := []string{}
|
||||||
for _, addr := range teachersList {
|
for _, addr := range teachersList {
|
||||||
@@ -192,7 +319,6 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
|||||||
}
|
}
|
||||||
if len(newList) == 0 {
|
if len(newList) == 0 {
|
||||||
delete(h.subscribers, sID)
|
delete(h.subscribers, sID)
|
||||||
// Nếu không còn ai xem học sinh này, gửi lệnh tắt camera/screen cho client học sinh
|
|
||||||
if student, exists := h.students[sID]; exists {
|
if student, exists := h.students[sID]; exists {
|
||||||
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
|
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
|
||||||
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
|
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
|
||||||
@@ -204,12 +330,10 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Teacher bắt đầu xem stream của Student
|
|
||||||
func (h *WsHub) Subscribe(teacherAddr string, studentID int64) {
|
func (h *WsHub) Subscribe(teacherAddr string, studentID int64) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
// Thêm giáo viên vào list người xem của học sinh
|
|
||||||
teachersList := h.subscribers[studentID]
|
teachersList := h.subscribers[studentID]
|
||||||
alreadySubscribed := false
|
alreadySubscribed := false
|
||||||
for _, addr := range teachersList {
|
for _, addr := range teachersList {
|
||||||
@@ -223,14 +347,12 @@ func (h *WsHub) Subscribe(teacherAddr string, studentID int64) {
|
|||||||
log.Printf("[WS] Teacher %s subscribed to student %d stream", teacherAddr, studentID)
|
log.Printf("[WS] Teacher %s subscribed to student %d stream", teacherAddr, studentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phát lệnh cho máy học sinh bật stream (nếu học sinh đang online)
|
|
||||||
if student, exists := h.students[studentID]; exists {
|
if student, exists := h.students[studentID]; exists {
|
||||||
_ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
_ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
||||||
_ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
_ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Teacher dừng xem stream của Student
|
|
||||||
func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
|
func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
@@ -250,8 +372,6 @@ func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
|
|||||||
if len(newList) == 0 {
|
if len(newList) == 0 {
|
||||||
delete(h.subscribers, studentID)
|
delete(h.subscribers, studentID)
|
||||||
log.Printf("[WS] Student %d has no more proctor subscribers. Stopping streams.", studentID)
|
log.Printf("[WS] Student %d has no more proctor subscribers. Stopping streams.", studentID)
|
||||||
|
|
||||||
// Báo học sinh tắt camera & screen stream để tiết kiệm mạng và CPU
|
|
||||||
if student, exists := h.students[studentID]; exists {
|
if student, exists := h.students[studentID]; exists {
|
||||||
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
|
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
|
||||||
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
|
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
|
||||||
@@ -261,7 +381,6 @@ func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chuyển tiếp frame ảnh từ Student đến các Teacher đã subscribe
|
|
||||||
func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
|
func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
teachersList, exists := h.subscribers[studentID]
|
teachersList, exists := h.subscribers[studentID]
|
||||||
@@ -269,7 +388,6 @@ func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
|
|||||||
h.mu.RUnlock()
|
h.mu.RUnlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Snapshot subscriber addresses while holding read lock
|
|
||||||
addrs := make([]string, len(teachersList))
|
addrs := make([]string, len(teachersList))
|
||||||
copy(addrs, teachersList)
|
copy(addrs, teachersList)
|
||||||
h.mu.RUnlock()
|
h.mu.RUnlock()
|
||||||
@@ -291,18 +409,30 @@ func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var dead []string
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
|
||||||
for _, addr := range addrs {
|
for _, addr := range addrs {
|
||||||
if t, found := h.teachers[addr]; found {
|
if t, found := h.teachers[addr]; found {
|
||||||
if err := t.WriteRaw(msgBytes); err != nil {
|
if err := t.WriteRaw(msgBytes); err != nil {
|
||||||
log.Printf("[WS] Relay to teacher %s failed: %v", addr, err)
|
log.Printf("[WS] Relay to teacher %s failed: %v", addr, err)
|
||||||
|
dead = append(dead, addr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, addr := range dead {
|
||||||
|
if t, ok := func() (*SocketClient, bool) {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
t, ok := h.teachers[addr]
|
||||||
|
return t, ok
|
||||||
|
}(); ok && t != nil {
|
||||||
|
_ = t.Conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket handler cho Fiber route
|
|
||||||
func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||||
return func(c *websocket.Conn) {
|
return func(c *websocket.Conn) {
|
||||||
role := c.Query("role", "student")
|
role := c.Query("role", "student")
|
||||||
@@ -351,7 +481,7 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
|||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
client.writeMu.Lock()
|
client.writeMu.Lock()
|
||||||
err := c.WriteMessage(websocket.PingMessage, nil)
|
err := c.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(5*time.Second))
|
||||||
client.writeMu.Unlock()
|
client.writeMu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -372,14 +502,14 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Xử lý các sự kiện
|
|
||||||
switch msg.Event {
|
switch msg.Event {
|
||||||
|
case "client:ping":
|
||||||
|
_ = client.WriteJSON(SocketMsg{Event: "client:pong", Data: map[string]any{"t": time.Now().UnixMilli()}})
|
||||||
|
|
||||||
case "screenshot_stream_frame", "webcam_stream_frame":
|
case "screenshot_stream_frame", "webcam_stream_frame":
|
||||||
// Nhận frame từ học sinh, chuyển tiếp về các thầy cô
|
|
||||||
Hub.RelayFrame(client.StudentID, msg.Event, msg.Data)
|
Hub.RelayFrame(client.StudentID, msg.Event, msg.Data)
|
||||||
|
|
||||||
case "teacher:subscribe":
|
case "teacher:subscribe":
|
||||||
// Giáo viên đăng ký xem học sinh cụ thể
|
|
||||||
if client.Role == "teacher" {
|
if client.Role == "teacher" {
|
||||||
if sIDVal, ok := msg.Data["studentId"]; ok {
|
if sIDVal, ok := msg.Data["studentId"]; ok {
|
||||||
var sID int64
|
var sID int64
|
||||||
@@ -396,7 +526,6 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "teacher:unsubscribe":
|
case "teacher:unsubscribe":
|
||||||
// Giáo viên hủy đăng ký
|
|
||||||
if client.Role == "teacher" {
|
if client.Role == "teacher" {
|
||||||
if sIDVal, ok := msg.Data["studentId"]; ok {
|
if sIDVal, ok := msg.Data["studentId"]; ok {
|
||||||
var sID int64
|
var sID int64
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ func main() {
|
|||||||
api.Get("/student/wifi-policy", handlers.GetStudentWifiPolicyHandler(gormDB))
|
api.Get("/student/wifi-policy", handlers.GetStudentWifiPolicyHandler(gormDB))
|
||||||
api.Post("/student/report-wifi", handlers.ReportWifiHandler(gormDB))
|
api.Post("/student/report-wifi", handlers.ReportWifiHandler(gormDB))
|
||||||
api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB))
|
api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB))
|
||||||
|
api.Post("/student/report-violation", handlers.ReportViolationHandler(gormDB))
|
||||||
api.Get("/student/chat/messages", handlers.StudentListChatHandler(gormDB))
|
api.Get("/student/chat/messages", handlers.StudentListChatHandler(gormDB))
|
||||||
api.Post("/student/chat/messages", handlers.StudentSendChatHandler(gormDB))
|
api.Post("/student/chat/messages", handlers.StudentSendChatHandler(gormDB))
|
||||||
api.Get("/student/chat/conversations", handlers.StudentListChatConversationsHandler(gormDB))
|
api.Get("/student/chat/conversations", handlers.StudentListChatConversationsHandler(gormDB))
|
||||||
@@ -156,6 +157,7 @@ func main() {
|
|||||||
staff.Post("/network/accepted-wifis/add", handlers.AddAcceptedWifiHandler(gormDB))
|
staff.Post("/network/accepted-wifis/add", handlers.AddAcceptedWifiHandler(gormDB))
|
||||||
staff.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(gormDB))
|
staff.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(gormDB))
|
||||||
staff.Get("/classes/:rkId/session-logs", handlers.ListClassSessionLogsHandler(gormDB))
|
staff.Get("/classes/:rkId/session-logs", handlers.ListClassSessionLogsHandler(gormDB))
|
||||||
|
staff.Get("/classes/:rkId/violations", handlers.ListClassViolationsHandler(gormDB))
|
||||||
staff.Get("/classes/:rkId/online-students", handlers.GetOnlineStudentsHandler(gormDB))
|
staff.Get("/classes/:rkId/online-students", handlers.GetOnlineStudentsHandler(gormDB))
|
||||||
staff.Get("/classes/:rkId/schedule-conflicts", handlers.GetClassScheduleConflictsHandler(gormDB))
|
staff.Get("/classes/:rkId/schedule-conflicts", handlers.GetClassScheduleConflictsHandler(gormDB))
|
||||||
|
|
||||||
@@ -187,6 +189,7 @@ func main() {
|
|||||||
staff.Get("/exam-rooms/search-students", handlers.SearchExamStudentsHandler(gormDB))
|
staff.Get("/exam-rooms/search-students", handlers.SearchExamStudentsHandler(gormDB))
|
||||||
staff.Get("/exam-rooms/:id", handlers.GetExamRoomHandler(gormDB))
|
staff.Get("/exam-rooms/:id", handlers.GetExamRoomHandler(gormDB))
|
||||||
staff.Get("/exam-rooms/:id/online-students", handlers.GetExamRoomOnlineStudentsHandler(gormDB))
|
staff.Get("/exam-rooms/:id/online-students", handlers.GetExamRoomOnlineStudentsHandler(gormDB))
|
||||||
|
staff.Get("/exam-rooms/:id/violations", handlers.ListExamRoomViolationsHandler(gormDB))
|
||||||
staff.Patch("/exam-rooms/:id", handlers.UpdateExamRoomHandler(gormDB))
|
staff.Patch("/exam-rooms/:id", handlers.UpdateExamRoomHandler(gormDB))
|
||||||
staff.Delete("/exam-rooms/:id", handlers.DeleteExamRoomHandler(gormDB))
|
staff.Delete("/exam-rooms/:id", handlers.DeleteExamRoomHandler(gormDB))
|
||||||
staff.Post("/exam-rooms/:id/students", handlers.AddExamRoomStudentsHandler(gormDB))
|
staff.Post("/exam-rooms/:id/students", handlers.AddExamRoomStudentsHandler(gormDB))
|
||||||
|
|||||||
Reference in New Issue
Block a user