This commit is contained in:
2026-06-30 09:31:33 +07:00
parent c736326162
commit bbf8336664
77 changed files with 12601 additions and 556 deletions

3
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
build/bin
node_modules
frontend/dist

19
client/README.md Normal file
View File

@@ -0,0 +1,19 @@
# README
## About
This is the official Wails Vanilla template.
You can configure the project by editing `wails.json`. More information about the project settings can be found
here: https://wails.io/docs/reference/project-config
## Live Development
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
to this in your browser, and you can call your Go code from devtools.
## Building
To build a redistributable, production mode package, use `wails build`.

921
client/app.go Normal file
View File

@@ -0,0 +1,921 @@
package main
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"client/internal/blocker"
"client/internal/screen"
"client/internal/winapi"
"github.com/gorilla/websocket"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
var blockedReportLast sync.Map // processName -> time.Time
var wifiReportLast sync.Map // ssidKey -> time.Time
var encryptionKey = []byte("simple-care-proctor-secretkey32!") // 32-byte key for AES-256
func encrypt(data []byte) ([]byte, error) {
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, data, nil)
return ciphertext, nil
}
func decrypt(data []byte) ([]byte, error) {
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return nil, errors.New("ciphertext too short")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
return gcm.Open(nil, nonce, ciphertext, nil)
}
type StudentData struct {
StudentID int64 `json:"studentId"`
FullName string `json:"fullName"`
Email string `json:"email"`
Avatar string `json:"avatar"`
Dob string `json:"dob"`
StudentCode string `json:"studentCode"`
Phone string `json:"phone"`
SystemID int64 `json:"systemId"`
}
type App struct {
ctx context.Context
student *StudentData
mu sync.Mutex
sessionPath string
statsPath string
wsConn *websocket.Conn
wsConnected bool
wifiSSID string
wifiBSSID string
allowedApps string
onlineSecs int
offlineSecs int
unsyncedOn int
unsyncedOff int
lastSyncTime time.Time
isStreamingSc bool
streamScStop chan struct{}
statusMsg string
expectingLogin bool
backendOnline bool
dashboard StudentDashboardSnapshot
wifiEnforce bool
acceptedWifi map[string]bool
wifiRejected bool
}
type LocalStats struct {
OnlineSecs int `json:"onlineSecs"`
OfflineSecs int `json:"offlineSecs"`
UnsyncedOn int `json:"unsyncedOn"`
UnsyncedOff int `json:"unsyncedOff"`
}
type StudentShiftSnapshot struct {
Period int `json:"period"`
CourseName string `json:"courseName"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
IsActiveNow bool `json:"isActiveNow"`
OnlineSeconds int `json:"onlineSeconds"`
OfflineSeconds int `json:"offlineSeconds"`
AttendanceStatus int `json:"attendanceStatus"`
AttendanceLabel string `json:"attendanceLabel"`
}
type StudentDashboardSnapshot struct {
SessionDate string `json:"sessionDate"`
MonitorMode string `json:"monitorMode"`
MonitorLabel string `json:"monitorLabel"`
ClassRkID int64 `json:"classRkId"`
ClassName string `json:"className"`
ClassCode string `json:"classCode"`
CurrentPeriod int `json:"currentPeriod"`
CurrentCourseName string `json:"currentCourseName"`
CurrentShiftStart string `json:"currentShiftStart"`
CurrentShiftEnd string `json:"currentShiftEnd"`
InScheduleNow bool `json:"inScheduleNow"`
BlockerActive bool `json:"blockerActive"`
Shifts []StudentShiftSnapshot `json:"shifts"`
}
func NewApp() *App {
configDir, err := os.UserConfigDir()
if err != nil {
// Fallback to executable folder if UserConfigDir is unavailable
exePath, _ := os.Executable()
configDir = filepath.Dir(exePath)
}
appDir := filepath.Join(configDir, "SimpleCare")
_ = os.MkdirAll(appDir, 0755)
return &App{
sessionPath: filepath.Join(appDir, "student_session.json"),
statsPath: filepath.Join(appDir, "student_stats.json"),
lastSyncTime: time.Now(),
expectingLogin: false,
}
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
a.loadSession()
a.loadStats()
// Register blocker callbacks
blocker.Instance.OnBlocked = func(procName string, title string) {
go a.reportBlockedApp(procName, title)
}
blocker.Instance.OnKill = func(procName string, title string) {
_, _ = runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
Type: runtime.WarningDialog,
Title: "Ứng dụng bị đóng",
Message: fmt.Sprintf("Hệ thống đã tự động đóng ứng dụng '%s' (%s) do không nằm trong danh sách các ứng dụng được phép sử dụng trong giờ học.", procName, title),
})
}
// Khởi chạy HTTP server nhận callback login thành công
a.startLocalServer()
// Khởi chạy vòng lặp giám sát định kỳ (mỗi 10 giây)
go a.monitorLoop()
// Khởi chạy vòng lặp đọc local storage của trang Rikkei Portal khi chưa đăng nhập
go a.authStorageScanner()
}
// startLocalServer khởi chạy server lắng nghe callback nhận thông tin sinh viên từ webview
func (a *App) startLocalServer() {
mux := http.NewServeMux()
mux.HandleFunc("/login-success", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
return
}
data := r.URL.Query().Get("data")
if data == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
a.mu.Lock()
expecting := a.expectingLogin
a.mu.Unlock()
if !expecting {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ignored"))
return
}
var st StudentData
if err := json.Unmarshal([]byte(data), &st); err == nil && st.StudentID > 0 {
a.saveSession(&st)
log.Printf("[LOCALSERVER] Student %s logged in successfully.", st.FullName)
a.mu.Lock()
a.expectingLogin = false
a.mu.Unlock()
blocker.Instance.Start()
// Kiểm tra điều kiện app được phép và giờ học trước khi tải giao diện
go a.fetchAllowedApps(st.SystemID)
go a.fetchStudentStatus(st.StudentID)
go a.fetchWifiPolicy()
// Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets
runtime.WindowReloadApp(a.ctx)
w.Write([]byte("ok"))
} else {
w.WriteHeader(http.StatusBadRequest)
}
})
server := &http.Server{
Addr: "127.0.0.1:34115",
Handler: mux,
}
go func() {
_ = server.ListenAndServe()
}()
log.Println("[LOCALSERVER] Callback server started on http://127.0.0.1:34115")
}
// loadSession nạp session từ file cục bộ
func (a *App) loadSession() {
a.mu.Lock()
defer a.mu.Unlock()
data, err := os.ReadFile(a.sessionPath)
if err == nil {
decrypted, errDec := decrypt(data)
if errDec == nil {
data = decrypted
}
var s StudentData
if err := json.Unmarshal(data, &s); err == nil {
a.student = &s
log.Printf("[APP] Loaded local session: %s (%s)", s.FullName, s.StudentCode)
blocker.Instance.Start()
go a.fetchAllowedApps(s.SystemID)
go a.fetchStudentStatus(s.StudentID)
go a.fetchWifiPolicy()
go a.syncProfileToServer(&s)
}
}
}
// saveSession lưu session ra file
func (a *App) saveSession(s *StudentData) {
a.mu.Lock()
defer a.mu.Unlock()
a.student = s
data, _ := json.MarshalIndent(s, "", " ")
encrypted, err := encrypt(data)
if err == nil {
_ = os.WriteFile(a.sessionPath, encrypted, 0644)
} else {
_ = os.WriteFile(a.sessionPath, data, 0644)
}
go a.syncProfileToServer(s)
}
func (a *App) syncProfileToServer(s *StudentData) {
if s == nil || s.Avatar == "" {
return
}
payload := map[string]any{
"studentRkId": s.StudentID,
"classRkId": s.SystemID,
"sessionDate": time.Now().Format("2006-01-02"),
"avatar": s.Avatar,
}
bodyBytes, _ := json.Marshal(payload)
client := http.Client{Timeout: 5 * time.Second}
resp, err := client.Post("http://127.0.0.1:8080/api/student/sync-log", "application/json", bytes.NewBuffer(bodyBytes))
if err != nil {
return
}
_ = resp.Body.Close()
}
// loadStats nạp stats
func (a *App) loadStats() {
a.mu.Lock()
defer a.mu.Unlock()
data, err := os.ReadFile(a.statsPath)
if err == nil {
var ls LocalStats
if err := json.Unmarshal(data, &ls); err == nil {
a.onlineSecs = ls.OnlineSecs
a.offlineSecs = ls.OfflineSecs
a.unsyncedOn = ls.UnsyncedOn
a.unsyncedOff = ls.UnsyncedOff
}
}
}
// saveStats lưu stats ra file
func (a *App) saveStats() {
a.mu.Lock()
defer a.mu.Unlock()
ls := LocalStats{
OnlineSecs: a.onlineSecs,
OfflineSecs: a.offlineSecs,
UnsyncedOn: a.unsyncedOn,
UnsyncedOff: a.unsyncedOff,
}
data, _ := json.Marshal(ls)
_ = os.WriteFile(a.statsPath, data, 0644)
}
// CheckLoginStatus kiểm tra xem sinh viên đã đăng nhập chưa
func (a *App) CheckLoginStatus() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.student != nil
}
// GetStudentInfo trả về thông tin sinh viên
func (a *App) GetStudentInfo() map[string]any {
a.mu.Lock()
defer a.mu.Unlock()
if a.student == nil {
return nil
}
return map[string]any{
"studentId": a.student.StudentID,
"fullName": a.student.FullName,
"email": a.student.Email,
"avatar": a.student.Avatar,
"dob": a.student.Dob,
"studentCode": a.student.StudentCode,
"phone": a.student.Phone,
"systemId": a.student.SystemID,
}
}
// NavigateToLogin chuyển WebView đến trang đăng nhập Rikkei Portal
func (a *App) NavigateToLogin() {
a.mu.Lock()
a.expectingLogin = true
a.mu.Unlock()
runtime.WindowExecJS(a.ctx, "window.location.href = 'https://portal.rikkei.edu.vn/dangnhap'")
}
// Logout đăng xuất sinh viên
func (a *App) Logout() {
a.mu.Lock()
a.student = nil
a.expectingLogin = false
a.mu.Unlock()
_ = os.Remove(a.sessionPath)
_ = os.Remove(a.statsPath)
a.mu.Lock()
a.onlineSecs = 0
a.offlineSecs = 0
a.unsyncedOn = 0
a.unsyncedOff = 0
a.mu.Unlock()
blocker.Instance.Stop()
a.disconnectWS()
a.stopScreenshotStream()
runtime.WindowReloadApp(a.ctx)
}
// GetStats trả về Wifi, thời gian online/offline và trạng thái giám sát
func (a *App) GetStats() map[string]any {
a.mu.Lock()
defer a.mu.Unlock()
shifts := make([]StudentShiftSnapshot, len(a.dashboard.Shifts))
copy(shifts, a.dashboard.Shifts)
for i := range shifts {
if shifts[i].IsActiveNow {
shifts[i].OnlineSeconds += a.unsyncedOn
shifts[i].OfflineSeconds += a.unsyncedOff
}
}
return map[string]any{
"wifiSSID": a.wifiSSID,
"onlineSecs": a.onlineSecs,
"offlineSecs": a.offlineSecs,
"wsConnected": a.wsConnected,
"serverReachable": a.backendOnline,
"allowedApps": a.allowedApps,
"statusMsg": a.statusMsg,
"sessionDate": a.dashboard.SessionDate,
"monitorMode": a.dashboard.MonitorMode,
"monitorLabel": a.dashboard.MonitorLabel,
"className": a.dashboard.ClassName,
"classCode": a.dashboard.ClassCode,
"currentPeriod": a.dashboard.CurrentPeriod,
"currentCourseName": a.dashboard.CurrentCourseName,
"currentShiftStart": a.dashboard.CurrentShiftStart,
"currentShiftEnd": a.dashboard.CurrentShiftEnd,
"inScheduleNow": a.dashboard.InScheduleNow,
"blockerActive": a.dashboard.BlockerActive,
"shifts": shifts,
}
}
// SendWebcamFrame truyền webcam frame từ JS frontend lên máy chủ qua WS
func (a *App) SendWebcamFrame(frameBase64 string) {
a.mu.Lock()
conn := a.wsConn
a.mu.Unlock()
if conn != nil {
_ = conn.WriteJSON(map[string]any{
"event": "webcam_stream_frame",
"data": map[string]any{
"imageBuffer": frameBase64,
},
})
}
}
// authStorageScanner chỉ quét localStorage khi người dùng chủ động mở trang đăng nhập
func (a *App) authStorageScanner() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
<-ticker.C
a.mu.Lock()
expecting := a.expectingLogin
loggedIn := a.student != nil
a.mu.Unlock()
if loggedIn || !expecting {
continue
}
runtime.WindowExecJS(a.ctx, `
try {
const st = localStorage.getItem("student");
if (st) {
fetch("http://127.0.0.1:34115/login-success?data=" + encodeURIComponent(st));
}
} catch(e) {}
`)
}
}
// monitorLoop định kỳ 10s: check wifi, ping backend, cập nhật online/offline seconds và đồng bộ lên server
func (a *App) monitorLoop() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
<-ticker.C
if !a.CheckLoginStatus() {
continue
}
wifi := winapi.GetWifiConnection()
a.mu.Lock()
a.wifiSSID = wifi.SSID
a.wifiBSSID = wifi.BSSID
a.mu.Unlock()
backendOnline := a.pingBackend()
if backendOnline && wifi.SSID != "" && wifi.BSSID != "" {
go a.reportWifi(wifi.SSID, wifi.BSSID)
a.fetchWifiPolicy()
if !a.isWifiAllowed(wifi.SSID, wifi.BSSID) {
a.rejectUnauthorizedWifi(wifi.SSID, wifi.BSSID)
continue
}
}
a.mu.Lock()
a.backendOnline = backendOnline
if backendOnline {
a.onlineSecs += 10
a.unsyncedOn += 10
} else {
a.offlineSecs += 10
a.unsyncedOff += 10
}
a.mu.Unlock()
a.saveStats()
if backendOnline {
a.connectWS()
a.syncLogsToServer()
} else {
a.disconnectWS()
}
}
}
func (a *App) pingBackend() bool {
client := http.Client{Timeout: 3 * time.Second}
resp, err := client.Get("http://127.0.0.1:8080/api/stats")
if err != nil {
return false
}
_ = resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
func (a *App) syncLogsToServer() {
a.mu.Lock()
student := a.student
unsyncedOn := a.unsyncedOn
unsyncedOff := a.unsyncedOff
ssid := a.wifiSSID
a.mu.Unlock()
if student == nil {
return
}
go a.fetchStudentStatus(student.StudentID)
go a.fetchAllowedApps(student.SystemID)
payload := map[string]any{
"studentRkId": student.StudentID,
"classRkId": student.SystemID,
"sessionDate": time.Now().Format("2006-01-02"),
"addOnlineSeconds": unsyncedOn,
"addOfflineSeconds": unsyncedOff,
"wifiSsid": ssid,
"avatar": student.Avatar,
}
bodyBytes, _ := json.Marshal(payload)
client := http.Client{Timeout: 5 * time.Second}
resp, err := client.Post("http://127.0.0.1:8080/api/student/sync-log", "application/json", bytes.NewBuffer(bodyBytes))
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
a.mu.Lock()
a.unsyncedOn -= unsyncedOn
a.unsyncedOff -= unsyncedOff
a.mu.Unlock()
a.saveStats()
log.Printf("[SYNC] Synced %ds online, %ds offline to server.", unsyncedOn, unsyncedOff)
}
}
func (a *App) reportBlockedApp(procName string, title string) {
a.mu.Lock()
student := a.student
a.mu.Unlock()
if student == nil || student.StudentID <= 0 {
return
}
procKey := strings.ToLower(strings.TrimSpace(procName))
if procKey == "" {
return
}
if v, ok := blockedReportLast.Load(procKey); ok {
if time.Since(v.(time.Time)) < 20*time.Second {
return
}
}
blockedReportLast.Store(procKey, time.Now())
payload := map[string]any{
"studentRkId": student.StudentID,
"processName": procName,
"windowTitle": title,
}
bodyBytes, _ := json.Marshal(payload)
client := http.Client{Timeout: 4 * time.Second}
resp, err := client.Post("http://127.0.0.1:8080/api/student/report-blocked-app", "application/json", bytes.NewBuffer(bodyBytes))
if err != nil {
log.Printf("[CLIENT] report-blocked-app failed: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Printf("[CLIENT] report-blocked-app status %d: %s", resp.StatusCode, string(body))
return
}
log.Printf("[CLIENT] report-blocked-app ok: %s", procName)
}
func (a *App) reportWifi(ssid, bssid string) {
a.mu.Lock()
student := a.student
a.mu.Unlock()
if student == nil || strings.TrimSpace(ssid) == "" || strings.TrimSpace(bssid) == "" {
return
}
key := winapi.BSSIDKey(bssid)
if len(key) != 12 {
return
}
if v, ok := wifiReportLast.Load(key); ok {
if time.Since(v.(time.Time)) < 30*time.Second {
return
}
}
wifiReportLast.Store(key, time.Now())
payload := map[string]any{
"studentRkId": student.StudentID,
"ssid": ssid,
"bssid": bssid,
}
bodyBytes, _ := json.Marshal(payload)
client := http.Client{Timeout: 4 * time.Second}
resp, err := client.Post("http://127.0.0.1:8080/api/student/report-wifi", "application/json", bytes.NewBuffer(bodyBytes))
if err != nil {
return
}
_ = resp.Body.Close()
}
func (a *App) fetchWifiPolicy() {
client := http.Client{Timeout: 4 * time.Second}
resp, err := client.Get("http://127.0.0.1:8080/api/student/wifi-policy")
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return
}
var res struct {
Enforce bool `json:"enforce"`
Accepted []struct {
SSID string `json:"ssid"`
BSSID string `json:"bssid"`
} `json:"accepted"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return
}
allowed := make(map[string]bool, len(res.Accepted))
for _, item := range res.Accepted {
k := winapi.BSSIDKey(item.BSSID)
if len(k) == 12 {
allowed[k] = true
}
}
a.mu.Lock()
a.wifiEnforce = res.Enforce
a.acceptedWifi = allowed
a.mu.Unlock()
}
func (a *App) isWifiAllowed(ssid, bssid string) bool {
a.mu.Lock()
enforce := a.wifiEnforce
allowed := a.acceptedWifi
a.mu.Unlock()
if !enforce || len(allowed) == 0 {
return true
}
bKey := winapi.BSSIDKey(bssid)
if bKey == "" || len(bKey) != 12 {
return false
}
return allowed[bKey]
}
func (a *App) rejectUnauthorizedWifi(ssid, bssid string) {
a.mu.Lock()
if a.wifiRejected {
a.mu.Unlock()
return
}
a.wifiRejected = true
a.mu.Unlock()
blocker.Instance.Stop()
a.disconnectWS()
_, _ = runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
Type: runtime.WarningDialog,
Title: "WiFi không được phép",
Message: 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),
})
runtime.Quit(a.ctx)
}
func (a *App) fetchAllowedApps(classId int64) {
a.mu.Lock()
studentID := int64(0)
if a.student != nil {
studentID = a.student.StudentID
}
a.mu.Unlock()
client := http.Client{Timeout: 4 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:8080/api/classes/%d/allowed-apps?studentId=%d", classId, studentID))
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var res struct {
Keywords string `json:"keywords"`
Exit bool `json:"exit"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err == nil {
if res.Exit {
reason := res.Reason
if reason == "" {
reason = "Không đáp ứng điều kiện học tập (chưa cấu hình ứng dụng được phép hoặc ngoài giờ học)."
}
log.Printf("[CLIENT] Conditions not met: %s. Blocker suspended.", reason)
a.mu.Lock()
a.allowedApps = ""
a.statusMsg = reason
a.mu.Unlock()
blocker.Instance.Stop()
return
}
a.mu.Lock()
a.allowedApps = res.Keywords
if a.dashboard.MonitorLabel != "" {
a.statusMsg = a.dashboard.MonitorLabel
} else {
a.statusMsg = "Đang trong giờ học"
}
a.mu.Unlock()
blocker.Instance.SetKeywords(res.Keywords)
blocker.Instance.Start()
}
}
}
func (a *App) fetchStudentStatus(studentID int64) {
if studentID <= 0 {
return
}
client := http.Client{Timeout: 4 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:8080/api/student/status?studentRkId=%d", studentID))
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return
}
var res StudentDashboardSnapshot
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return
}
a.mu.Lock()
a.dashboard = res
if res.MonitorLabel != "" {
a.statusMsg = res.MonitorLabel
}
if res.ClassName != "" && res.MonitorMode == "learning" {
a.statusMsg = fmt.Sprintf("%s · %s", res.ClassName, res.MonitorLabel)
} else if res.ClassName != "" && res.MonitorMode == "outside_schedule" {
a.statusMsg = fmt.Sprintf("%s · Ngoài giờ học", res.ClassName)
} else if res.ClassName != "" && res.MonitorMode == "not_configured" {
a.statusMsg = fmt.Sprintf("%s · %s", res.ClassName, res.MonitorLabel)
}
a.mu.Unlock()
}
func (a *App) connectWS() {
a.mu.Lock()
if a.wsConnected || a.student == nil {
a.mu.Unlock()
return
}
student := a.student
a.mu.Unlock()
wsUrl := fmt.Sprintf("ws://127.0.0.1:8080/ws?role=student&studentId=%d&classId=%d", student.StudentID, student.SystemID)
dialer := websocket.Dialer{HandshakeTimeout: 4 * time.Second}
conn, _, err := dialer.Dial(wsUrl, nil)
if err != nil {
return
}
a.mu.Lock()
a.wsConn = conn
a.wsConnected = true
a.mu.Unlock()
log.Println("[WS] Connected to proctor websocket hub.")
go func() {
defer a.disconnectWS()
for {
var msg struct {
Event string `json:"event"`
Data map[string]any `json:"data"`
}
err := conn.ReadJSON(&msg)
if err != nil {
break
}
switch msg.Event {
case "start_screenshot_stream":
a.startScreenshotStream()
case "stop_screenshot_stream":
a.stopScreenshotStream()
case "start_webcam_stream":
runtime.EventsEmit(a.ctx, "start_webcam_stream")
case "stop_webcam_stream":
runtime.EventsEmit(a.ctx, "stop_webcam_stream")
}
}
}()
}
func (a *App) disconnectWS() {
a.mu.Lock()
defer a.mu.Unlock()
if a.wsConn != nil {
_ = a.wsConn.Close()
a.wsConn = nil
}
a.wsConnected = false
}
func (a *App) startScreenshotStream() {
a.mu.Lock()
if a.isStreamingSc {
a.mu.Unlock()
return
}
a.isStreamingSc = true
a.streamScStop = make(chan struct{})
a.mu.Unlock()
go func() {
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
frame, err := screen.CaptureScreen()
if err != nil {
continue
}
a.mu.Lock()
conn := a.wsConn
a.mu.Unlock()
if conn != nil {
_ = conn.WriteJSON(map[string]any{
"event": "screenshot_stream_frame",
"data": map[string]any{
"imageBuffer": frame,
},
})
}
case <-a.streamScStop:
return
}
}
}()
log.Println("[WS] Screenshot screen-streaming started.")
}
func (a *App) stopScreenshotStream() {
a.mu.Lock()
defer a.mu.Unlock()
if !a.isStreamingSc {
return
}
a.isStreamingSc = false
close(a.streamScStop)
log.Println("[WS] Screenshot screen-streaming stopped.")
}

62
client/build.ps1 Normal file
View File

@@ -0,0 +1,62 @@
# PowerShell script to build Wails client
$ErrorActionPreference = "Stop"
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host " BUILDING WAILS CLIENT " -ForegroundColor Cyan
Write-Host "=============================================" -ForegroundColor Cyan
# Check if wails is installed
$wailsInstalled = $null -ne (Get-Command wails -ErrorAction SilentlyContinue)
if ($wailsInstalled) {
Write-Host "[*] Found Wails CLI. Building via Wails..." -ForegroundColor Green
try {
wails build
Write-Host "[+] Build completed successfully using Wails CLI!" -ForegroundColor Green
exit 0
} catch {
Write-Host "[-] Wails build failed. Attempting manual fallback build..." -ForegroundColor Yellow
}
} else {
Write-Host "[!] Wails CLI not found. Falling back to manual build..." -ForegroundColor Yellow
}
# Check for Go
if ($null -eq (Get-Command go -ErrorAction SilentlyContinue)) {
Write-Host "[!] Go is not installed or not in PATH." -ForegroundColor Red
exit 1
}
# Check for npm
if ($null -eq (Get-Command npm -ErrorAction SilentlyContinue)) {
Write-Host "[!] Node.js/npm is not installed or not in PATH." -ForegroundColor Red
exit 1
}
# Step 1: Build Frontend
Write-Host "[*] Building frontend assets..." -ForegroundColor Cyan
Push-Location frontend
try {
if (-not (Test-Path "node_modules")) {
Write-Host "[*] node_modules not found. Running 'npm install'..." -ForegroundColor Cyan
npm install
}
Write-Host "[*] Running 'npm run build'..." -ForegroundColor Cyan
npm run build
} finally {
Pop-Location
}
# Step 2: Build Backend Go binary
Write-Host "[*] Building Go application..." -ForegroundColor Cyan
try {
# -H windowsgui: prevents console window from flashing on startup
go build -ldflags="-s -w -H windowsgui" -o client.exe main.go app.go
Write-Host "[+] Build completed successfully! Generated client.exe" -ForegroundColor Green
} catch {
Write-Host "[-] Go build failed." -ForegroundColor Red
exit 1
}

35
client/build/README.md Normal file
View File

@@ -0,0 +1,35 @@
# Build Directory
The build directory is used to house all the build files and assets for your application.
The structure is:
* bin - Output directory
* darwin - macOS specific files
* windows - Windows specific files
## Mac
The `darwin` directory holds files specific to Mac builds.
These may be customised and used as part of the build. To return these files to the default state, simply delete them
and
build with `wails build`.
The directory contains the following files:
- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
## Windows
The `windows` directory contains the manifest and rc files used when building with `wails build`.
These may be customised for your application. To return these files to the default state, simply delete them and
build with `wails build`.
- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
will be created using the `appicon.png` file in the build directory.
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
as well as the application itself (right click the exe -> properties -> details)
- `wails.exe.manifest` - The main application manifest file.

BIN
client/build/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -0,0 +1,68 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,63 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "{{.Info.ProductVersion}}"
},
"info": {
"0000": {
"ProductVersion": "{{.Info.ProductVersion}}",
"CompanyName": "{{.Info.CompanyName}}",
"FileDescription": "{{.Info.ProductName}}",
"LegalCopyright": "{{.Info.Copyright}}",
"ProductName": "{{.Info.ProductName}}",
"Comments": "{{.Info.Comments}}"
}
}
}

View File

@@ -0,0 +1,114 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd

View File

@@ -0,0 +1,249 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "{{.Name}}"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKLM "${UNINST_KEY}"
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "tmp\MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
{{range .Info.FileAssociations}}
!insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
File "..\{{.IconName}}.ico"
{{end}}
!macroend
!macro wails.unassociateFiles
; Delete app associations
{{range .Info.FileAssociations}}
!insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
Delete "$INSTDIR\{{.IconName}}.ico"
{{end}}
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
{{end}}
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
{{end}}
!macroend

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>

BIN
client/client.exe Normal file

Binary file not shown.

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Simple Care — Rikkei Education</title>
</head>
<body>
<div id="app"></div>
<script src="./src/main.js" type="module"></script>
</body>
</html>

664
client/frontend/package-lock.json generated Normal file
View File

@@ -0,0 +1,664 @@
{
"name": "frontend",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.0.0",
"devDependencies": {
"vite": "^3.0.7"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz",
"integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz",
"integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz",
"integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/android-arm": "0.15.18",
"@esbuild/linux-loong64": "0.15.18",
"esbuild-android-64": "0.15.18",
"esbuild-android-arm64": "0.15.18",
"esbuild-darwin-64": "0.15.18",
"esbuild-darwin-arm64": "0.15.18",
"esbuild-freebsd-64": "0.15.18",
"esbuild-freebsd-arm64": "0.15.18",
"esbuild-linux-32": "0.15.18",
"esbuild-linux-64": "0.15.18",
"esbuild-linux-arm": "0.15.18",
"esbuild-linux-arm64": "0.15.18",
"esbuild-linux-mips64le": "0.15.18",
"esbuild-linux-ppc64le": "0.15.18",
"esbuild-linux-riscv64": "0.15.18",
"esbuild-linux-s390x": "0.15.18",
"esbuild-netbsd-64": "0.15.18",
"esbuild-openbsd-64": "0.15.18",
"esbuild-sunos-64": "0.15.18",
"esbuild-windows-32": "0.15.18",
"esbuild-windows-64": "0.15.18",
"esbuild-windows-arm64": "0.15.18"
}
},
"node_modules/esbuild-android-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz",
"integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-android-arm64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz",
"integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-darwin-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz",
"integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-darwin-arm64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz",
"integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-freebsd-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz",
"integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-freebsd-arm64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz",
"integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-32": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz",
"integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz",
"integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-arm": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz",
"integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-arm64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz",
"integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-mips64le": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz",
"integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-ppc64le": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz",
"integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-riscv64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz",
"integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-s390x": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz",
"integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-netbsd-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz",
"integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-openbsd-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz",
"integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-sunos-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz",
"integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-windows-32": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz",
"integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-windows-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz",
"integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-windows-arm64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz",
"integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/is-core-module": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rollup": {
"version": "2.80.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=10.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/vite": {
"version": "3.2.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz",
"integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.15.9",
"postcss": "^8.4.18",
"resolve": "^1.22.1",
"rollup": "^2.79.1"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
},
"peerDependencies": {
"@types/node": ">= 14",
"less": "*",
"sass": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
}
}
}

View File

@@ -0,0 +1,13 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^3.0.7"
}
}

View File

@@ -0,0 +1 @@
5fbf12469d224a93954efecb5886e8a6

691
client/frontend/src/app.css Normal file
View File

@@ -0,0 +1,691 @@
@import url('https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600;700;800&family=Share+Tech+Mono&display=swap');
:root {
--bg-main: #f5f7fa;
--bg-card: #ffffff;
--bg-subtle: #f0f3f7;
--border-color: #e4e9f0;
--accent: #bb2126;
--accent-dark: #9e1c22;
--accent-light: #fde8e9;
--accent-glow: rgba(187, 33, 38, 0.12);
--text-primary: #1a2332;
--text-secondary: #5a6a7e;
--text-muted: #8b99a8;
--online-color: #0d9f6e;
--online-light: #e6f7f1;
--offline-color: #d63031;
--offline-light: #fdeaea;
--shadow-sm: 0 1px 3px rgba(26, 35, 50, 0.05);
--shadow-md: 0 4px 16px rgba(26, 35, 50, 0.07);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--transition: 0.2s ease;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
height: 100%;
overflow: hidden;
}
body {
margin: 0;
background-color: var(--bg-main);
color: var(--text-primary);
font-family: 'Be Vietnam Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
overflow: hidden;
height: 100%;
width: 100%;
-webkit-font-smoothing: antialiased;
}
#app {
width: 100%;
height: 100%;
max-height: 100vh;
display: flex;
overflow: hidden;
}
/* ── Cards ── */
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 1.75rem;
transition: var(--transition);
}
/* ── Login Screen ── */
.login-prompt-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 1.5rem;
background: var(--bg-main);
overflow: hidden;
}
.login-prompt-container .card {
max-width: 420px;
width: 100%;
max-height: calc(100vh - 3rem);
overflow-y: auto;
text-align: center;
animation: floatIn 0.4s ease forwards;
}
.login-brand-row {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.login-logo {
width: 48px;
height: 48px;
background: var(--accent);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 0.9rem;
color: white;
flex-shrink: 0;
}
.login-brand-text {
text-align: left;
}
.login-brand-name {
font-size: 1.1rem;
font-weight: 800;
color: var(--text-primary);
letter-spacing: -0.3px;
line-height: 1.2;
}
.login-brand-sub {
font-size: 0.72rem;
font-weight: 500;
color: var(--accent);
}
.login-title {
font-size: 1.35rem;
font-weight: 800;
margin-bottom: 0.6rem;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.login-desc {
color: var(--text-secondary);
font-size: 0.875rem;
line-height: 1.65;
margin-bottom: 1.75rem;
}
/* ── Dashboard Layout ── */
.dashboard-wrapper {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
max-height: 100vh;
padding: 1.25rem 1.5rem;
box-sizing: border-box;
overflow: hidden;
animation: fadeIn 0.35s ease-out;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.brand-logo {
width: 38px;
height: 38px;
background: var(--accent);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 0.85rem;
color: white;
flex-shrink: 0;
}
.brand-text {
display: flex;
flex-direction: column;
gap: 0.05rem;
}
.brand-name {
font-weight: 800;
font-size: 1.05rem;
letter-spacing: -0.3px;
color: var(--text-primary);
line-height: 1.2;
}
.brand-sub {
font-size: 0.7rem;
font-weight: 500;
color: var(--accent);
}
.main-grid {
display: grid;
grid-template-columns: minmax(220px, 260px) 1fr;
gap: 1rem;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ── Profile Card ── */
.profile-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
height: 100%;
min-height: 0;
overflow-y: auto;
justify-content: flex-start;
padding-top: 1rem;
}
.avatar-container {
position: relative;
margin-bottom: 1.25rem;
}
.avatar {
width: 84px;
height: 84px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--accent-light);
padding: 3px;
background: var(--bg-subtle);
}
.status-indicator {
position: absolute;
bottom: 6px;
right: 6px;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2.5px solid #fff;
}
.status-indicator.online {
background-color: var(--online-color);
animation: pulseGreen 2s infinite;
}
.status-indicator.offline {
background-color: var(--offline-color);
animation: pulseRed 2s infinite;
}
.student-name {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.student-code {
background: var(--accent-light);
color: var(--accent);
padding: 3px 10px;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 700;
margin-top: 0.4rem;
margin-bottom: 0.5rem;
border: 1px solid rgba(187, 33, 38, 0.15);
letter-spacing: 0.3px;
display: inline-block;
}
.student-email {
margin: 0;
color: var(--text-muted);
font-size: 0.82rem;
}
.divider {
width: 100%;
height: 1px;
background: var(--border-color);
margin: 1.25rem 0;
}
.profile-info-row {
display: flex;
justify-content: space-between;
width: 100%;
font-size: 0.82rem;
margin-bottom: 0.6rem;
}
.profile-info-row span {
color: var(--text-muted);
}
.profile-info-row strong {
color: var(--text-primary);
font-weight: 600;
}
/* ── Right Column ── */
.right-column {
display: flex;
flex-direction: column;
gap: 0.85rem;
overflow: hidden;
height: 100%;
min-height: 0;
}
.status-banner {
padding: 0.75rem 1rem;
display: flex;
align-items: flex-start;
gap: 0.75rem;
border-left: 3px solid var(--accent);
flex-shrink: 0;
}
.status-banner-main {
flex: 1;
min-width: 0;
}
.status-banner-top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.65rem;
margin-bottom: 0.25rem;
}
.monitor-mode-tag {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.45rem;
border-radius: 4px;
background: var(--accent-light);
color: var(--accent);
}
.status-class-line {
font-size: 0.78rem;
font-weight: 600;
color: var(--text-secondary);
}
.status-banner-sub {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 0.2rem;
}
.status-banner.mode-learning { border-left-color: var(--online-color); }
.status-banner.mode-learning .monitor-mode-tag { background: var(--online-light); color: var(--online-color); }
.status-banner.mode-outside { border-left-color: #94a3b8; }
.status-banner.mode-outside .monitor-mode-tag { background: #f1f5f9; color: #64748b; }
.status-banner.mode-config { border-left-color: #f59e0b; }
.status-banner.mode-config .monitor-mode-tag { background: #fffbeb; color: #b45309; }
.status-banner.mode-exam { border-left-color: #7c3aed; }
.status-banner.mode-exam .monitor-mode-tag { background: #f5f3ff; color: #6d28d9; }
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.65rem;
}
.stats-grid--two {
grid-template-columns: 1fr 1fr;
}
.connectivity-note {
font-size: 0.75rem;
line-height: 1.45;
color: var(--text-muted);
padding: 0.65rem 0.85rem;
background: #f8fafc;
border: 1px solid var(--border-color);
}
.connectivity-note strong {
color: var(--text-primary);
}
.conn-split {
font-weight: 600;
color: var(--online-color);
}
.conn-split.offline-text {
color: var(--offline-color);
}
.card-title-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.65rem;
}
.card-title-sub {
font-size: 0.75rem;
color: var(--text-muted);
font-family: monospace;
}
.clock-display-summary {
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px dashed var(--border-color);
}
.shifts-wrap {
overflow: auto;
max-height: min(32vh, 220px);
}
.shifts-table {
width: 100%;
border-collapse: collapse;
font-size: 0.76rem;
}
.shifts-table th {
text-align: left;
padding: 0.4rem 0.35rem;
color: var(--text-muted);
font-weight: 600;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
background: #fff;
}
.shifts-table td {
padding: 0.45rem 0.35rem;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.shift-row-active {
background: #f0fdf8;
}
.shift-now {
font-size: 0.65rem;
color: var(--online-color);
font-weight: 700;
text-transform: uppercase;
}
.shift-time {
font-family: monospace;
font-size: 0.72rem;
color: var(--text-muted);
}
.time-online { color: var(--online-color); font-family: monospace; font-weight: 600; }
.time-offline { color: var(--offline-color); font-family: monospace; }
.att-badge {
display: inline-block;
padding: 0.12rem 0.4rem;
border-radius: 4px;
font-size: 0.68rem;
font-weight: 600;
white-space: nowrap;
}
.att-ok { background: var(--online-light); color: var(--online-color); }
.att-late { background: #fffbeb; color: #b45309; }
.att-leave { background: #eff6ff; color: #2563eb; }
.att-absent { background: var(--offline-light); color: var(--offline-color); }
.att-pending { background: #f1f5f9; color: #64748b; }
.shifts-empty {
padding: 1rem;
text-align: center;
color: var(--text-muted);
font-size: 0.82rem;
}
.status-banner-icon {
width: 36px;
height: 36px;
background: var(--accent-light);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
flex-shrink: 0;
}
.status-banner-label {
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status-banner-value {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
}
.stat-card {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.9rem 1rem;
}
.stat-icon-wrap {
width: 40px;
height: 40px;
background: var(--bg-subtle);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
}
.stat-info {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.stat-title {
font-size: 0.72rem;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.stat-value {
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Clocks ── */
.clocks-card {
display: flex;
flex-direction: column;
gap: 0.65rem;
flex: 1;
min-height: 0;
overflow: hidden;
}
.card-title {
font-size: 0.85rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.2px;
flex-shrink: 0;
}
.clock-display {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem;
flex: 1;
min-height: 0;
}
.clock-item {
background: var(--bg-subtle);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 0.75rem 0.85rem;
text-align: center;
transition: var(--transition);
display: flex;
flex-direction: column;
justify-content: center;
}
.clock-label {
font-size: 0.68rem;
color: var(--text-muted);
font-weight: 600;
margin-bottom: 0.4rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.clock-value {
font-size: clamp(1.4rem, 3.5vw, 1.85rem);
font-weight: 800;
font-family: 'Share Tech Mono', monospace;
letter-spacing: 1px;
}
.clock-value.online {
color: var(--online-color);
}
.clock-value.offline {
color: var(--offline-color);
}
/* ── Buttons ── */
.btn {
padding: 0.65rem 1.15rem;
border-radius: var(--radius-sm);
border: none;
font-weight: 600;
font-size: 0.875rem;
font-family: 'Be Vietnam Pro', sans-serif;
cursor: pointer;
transition: var(--transition);
}
.btn-primary {
background: var(--accent);
color: white;
box-shadow: 0 2px 8px var(--accent-glow);
}
.btn-primary:hover {
background: var(--accent-dark);
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(187, 33, 38, 0.25);
}
.btn-logout {
background: var(--bg-subtle);
color: var(--text-secondary);
border: 1px solid var(--border-color);
padding: 0.5rem 0.9rem;
font-size: 0.82rem;
}
.btn-logout:hover {
background: var(--offline-light);
color: var(--offline-color);
border-color: rgba(214, 48, 49, 0.25);
}
/* ── Animations ── */
@keyframes floatIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pulseGreen {
0% { box-shadow: 0 0 0 0 rgba(13, 159, 110, 0.35); }
70% { box-shadow: 0 0 0 8px rgba(13, 159, 110, 0); }
100% { box-shadow: 0 0 0 0 rgba(13, 159, 110, 0); }
}
@keyframes pulseRed {
0% { box-shadow: 0 0 0 0 rgba(214, 48, 49, 0.35); }
70% { box-shadow: 0 0 0 8px rgba(214, 48, 49, 0); }
100% { box-shadow: 0 0 0 0 rgba(214, 48, 49, 0); }
}

View File

@@ -0,0 +1,93 @@
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

368
client/frontend/src/main.js Normal file
View File

@@ -0,0 +1,368 @@
import './style.css';
import './app.css';
// Trạng thái cục bộ
let loggedIn = false;
let studentInfo = null;
let stats = {
wifiSSID: '',
onlineSecs: 0,
offlineSecs: 0,
wsConnected: false,
serverReachable: false,
allowedApps: '',
monitorMode: '',
monitorLabel: '',
className: '',
classCode: '',
currentPeriod: 0,
currentCourseName: '',
shifts: []
};
// Quản lý webcam
let webcamStream = null;
let webcamInterval = null;
const webcamVideo = document.createElement('video');
webcamVideo.autoplay = true;
webcamVideo.playsInline = true;
webcamVideo.style.display = 'none';
document.body.appendChild(webcamVideo);
const webcamCanvas = document.createElement('canvas');
webcamCanvas.width = 320;
webcamCanvas.height = 240;
webcamCanvas.style.display = 'none';
document.body.appendChild(webcamCanvas);
function init() {
if (typeof window.go === 'undefined' || typeof window.go.main === 'undefined') {
setTimeout(init, 200);
return;
}
window.runtime.EventsOn('start_webcam_stream', startWebcam);
window.runtime.EventsOn('stop_webcam_stream', stopWebcam);
checkLogin();
}
async function checkLogin() {
try {
const isLogged = await window.go.main.App.CheckLoginStatus();
if (isLogged) {
loggedIn = true;
studentInfo = await window.go.main.App.GetStudentInfo();
renderDashboard();
startStatsTicker();
} else {
loggedIn = false;
renderLoginPrompt();
}
} catch (err) {
console.error('Error checking login status:', err);
}
}
function renderLoginPrompt() {
document.querySelector('#app').innerHTML = `
<div class="login-prompt-container">
<div class="card">
<div class="login-brand-row">
<div class="login-logo">RE</div>
<div class="login-brand-text">
<div class="login-brand-name">Simple Care</div>
<div class="login-brand-sub">Rikkei Education</div>
</div>
</div>
<h2 class="login-title">Đăng nhập sinh viên</h2>
<p class="login-desc">
Ứng dụng sẽ chuyển hướng bạn đến trang Rikkei Portal. Sau khi đăng nhập thành công, hệ thống sẽ tự động đồng bộ tài khoản học tập của bạn.
</p>
<button class="btn btn-primary" id="btn-goto-login" style="width: 100%; padding: 0.75rem;">
Đi đến trang đăng nhập
</button>
</div>
</div>
`;
document.getElementById('btn-goto-login').addEventListener('click', () => {
window.go.main.App.NavigateToLogin();
});
}
function monitorModeMeta(mode) {
switch (mode) {
case 'learning':
return { icon: '🛡️', label: 'Đang giám sát', cls: 'mode-learning' };
case 'exam':
return { icon: '📝', label: 'Phòng thi', cls: 'mode-exam' };
case 'outside_schedule':
return { icon: '😴', label: 'Ngoài giờ học', cls: 'mode-outside' };
case 'not_configured':
return { icon: '⚙️', label: 'Chưa sẵn sàng', cls: 'mode-config' };
default:
return { icon: '⏳', label: 'Đang kết nối', cls: 'mode-pending' };
}
}
function attendanceClass(status) {
if (status === 4) return 'att-ok';
if (status === 3) return 'att-late';
if (status === 1 || status === 2) return 'att-leave';
if (status === 0) return 'att-absent';
return 'att-pending';
}
function renderShiftsTable(shifts) {
if (!shifts || shifts.length === 0) {
return '<div class="shifts-empty">Hôm nay không có ca học theo lịch lớp.</div>';
}
return `
<table class="shifts-table">
<thead>
<tr>
<th>Ca</th>
<th>Môn học</th>
<th>Giờ</th>
<th>Trực tuyến</th>
<th>Ngoại tuyến</th>
<th>Điểm danh</th>
</tr>
</thead>
<tbody>
${shifts.map(s => `
<tr class="${s.isActiveNow ? 'shift-row-active' : ''}">
<td><strong>Ca ${s.period}</strong>${s.isActiveNow ? ' <span class="shift-now">đang học</span>' : ''}</td>
<td>${s.courseName || '—'}</td>
<td class="shift-time">${s.startTime}${s.endTime}</td>
<td class="time-online">${formatDuration(s.onlineSeconds || 0)}</td>
<td class="time-offline">${formatDuration(s.offlineSeconds || 0)}</td>
<td><span class="att-badge ${attendanceClass(s.attendanceStatus)}">${s.attendanceLabel || 'Chưa tính'}</span></td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
function renderDashboard() {
if (!studentInfo) return;
const mode = monitorModeMeta(stats.monitorMode);
const classLine = stats.className
? `${stats.className}${stats.classCode ? ` (${stats.classCode})` : ''}`
: 'Đang xác định lớp...';
document.querySelector('#app').innerHTML = `
<div class="dashboard-wrapper">
<header class="header">
<div class="brand">
<div class="brand-logo">RE</div>
<div class="brand-text">
<span class="brand-name">Simple Care</span>
<span class="brand-sub">Rikkei Education</span>
</div>
</div>
<button class="btn btn-logout" id="btn-logout">Đăng xuất</button>
</header>
<div class="main-grid">
<div class="card profile-card">
<div class="avatar-container">
<img src="${studentInfo.avatar || 'https://via.placeholder.com/150'}" alt="Avatar" class="avatar" />
<div class="status-indicator ${stats.serverReachable ? 'online' : 'offline'}"></div>
</div>
<h2 class="student-name">${studentInfo.fullName}</h2>
<div class="student-code">${studentInfo.studentCode}</div>
<p class="student-email">${studentInfo.email}</p>
<div class="divider"></div>
<div class="profile-info-row">
<span>Lớp</span>
<strong id="profile-class">${classLine}</strong>
</div>
<div class="profile-info-row">
<span>Điện thoại</span>
<strong>${studentInfo.phone || 'Chưa cung cấp'}</strong>
</div>
</div>
<div class="right-column">
<div class="card status-banner ${mode.cls}">
<div class="status-banner-icon" id="status-icon">${mode.icon}</div>
<div class="status-banner-main">
<div class="status-banner-top">
<span class="monitor-mode-tag" id="monitor-mode-tag">${mode.label}</span>
<span class="status-class-line" id="status-class-line">${classLine}</span>
</div>
<div class="status-banner-value" id="status-message">${stats.monitorLabel || stats.statusMsg || 'Đang kết nối...'}</div>
<div class="status-banner-sub" id="status-sub">
${stats.currentPeriod > 0 && stats.currentCourseName
? `Ca ${stats.currentPeriod} · ${stats.currentCourseName}`
: 'Theo dõi theo lịch học của lớp'}
</div>
</div>
</div>
<div class="stats-grid stats-grid--two">
<div class="card stat-card">
<div class="stat-icon-wrap">📡</div>
<div class="stat-info">
<div class="stat-title">WiFi</div>
<div class="stat-value" id="wifi-ssid">${stats.wifiSSID || 'Đang quét...'}</div>
</div>
</div>
<div class="card stat-card">
<div class="stat-icon-wrap">🖥️</div>
<div class="stat-info">
<div class="stat-title">Máy chủ</div>
<div class="stat-value" id="server-status" style="color: ${stats.serverReachable ? 'var(--online-color)' : 'var(--offline-color)'}">
${stats.serverReachable ? 'Đã kết nối' : 'Mất kết nối'}
</div>
</div>
</div>
</div>
<div class="clocks-card card">
<div class="card-title-row">
<div class="card-title">Hôm nay — theo ca</div>
<div class="card-title-sub" id="session-date">${stats.sessionDate || ''}</div>
</div>
<div class="clock-display clock-display-summary">
<div class="clock-item">
<div class="clock-label">Tổng trực tuyến</div>
<div class="clock-value online" id="clock-online">00:00:00</div>
</div>
<div class="clock-item">
<div class="clock-label">Tổng ngoại tuyến</div>
<div class="clock-value offline" id="clock-offline">00:00:00</div>
</div>
</div>
<div class="shifts-wrap" id="shifts-wrap">
${renderShiftsTable(stats.shifts)}
</div>
</div>
</div>
</div>
</div>
`;
document.getElementById('btn-logout').addEventListener('click', async () => {
if (confirm('Bạn có chắc chắn muốn đăng xuất khỏi ứng dụng giám sát?')) {
loggedIn = false;
studentInfo = null;
stopWebcam();
await window.go.main.App.Logout();
}
});
}
function formatDuration(totalSeconds) {
const hrs = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
const pad = (num) => String(num).padStart(2, '0');
return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
}
function startStatsTicker() {
setInterval(async () => {
if (!loggedIn) return;
try {
const freshStats = await window.go.main.App.GetStats();
stats = { ...stats, ...freshStats };
if (!Array.isArray(stats.shifts)) stats.shifts = [];
const wifiEl = document.getElementById('wifi-ssid');
if (wifiEl) wifiEl.innerText = stats.wifiSSID || 'Không có WiFi';
const serverEl = document.getElementById('server-status');
if (serverEl) {
serverEl.innerText = stats.serverReachable ? 'Đã kết nối' : 'Mất kết nối';
serverEl.style.color = stats.serverReachable ? 'var(--online-color)' : 'var(--offline-color)';
}
const statusMsgEl = document.getElementById('status-message');
if (statusMsgEl) {
statusMsgEl.innerText = stats.monitorLabel || stats.statusMsg || 'Đang kết nối...';
}
const mode = monitorModeMeta(stats.monitorMode);
const statusIconEl = document.getElementById('status-icon');
if (statusIconEl) statusIconEl.innerText = mode.icon;
const modeTagEl = document.getElementById('monitor-mode-tag');
if (modeTagEl) modeTagEl.innerText = mode.label;
const banner = document.querySelector('.status-banner');
if (banner) banner.className = `card status-banner ${mode.cls}`;
const classLine = stats.className
? `${stats.className}${stats.classCode ? ` (${stats.classCode})` : ''}`
: 'Đang xác định lớp...';
const classLineEl = document.getElementById('status-class-line');
if (classLineEl) classLineEl.innerText = classLine;
const profileClassEl = document.getElementById('profile-class');
if (profileClassEl) profileClassEl.innerText = classLine;
const statusSubEl = document.getElementById('status-sub');
if (statusSubEl) {
statusSubEl.innerText = stats.currentPeriod > 0 && stats.currentCourseName
? `Ca ${stats.currentPeriod} · ${stats.currentCourseName}`
: 'Theo dõi theo lịch học của lớp';
}
const onlineEl = document.getElementById('clock-online');
if (onlineEl) onlineEl.innerText = formatDuration(stats.onlineSecs || 0);
const offlineEl = document.getElementById('clock-offline');
if (offlineEl) offlineEl.innerText = formatDuration(stats.offlineSecs || 0);
const shiftsWrap = document.getElementById('shifts-wrap');
if (shiftsWrap) shiftsWrap.innerHTML = renderShiftsTable(stats.shifts);
const sessionDateEl = document.getElementById('session-date');
if (sessionDateEl && stats.sessionDate) sessionDateEl.innerText = stats.sessionDate;
const indicator = document.querySelector('.status-indicator');
if (indicator) {
indicator.className = `status-indicator ${stats.serverReachable ? 'online' : 'offline'}`;
}
} catch (err) {
console.error('Error fetching stats:', err);
}
}, 1000);
}
async function startWebcam() {
if (webcamStream) return;
try {
webcamStream = await navigator.mediaDevices.getUserMedia({
video: { width: 320, height: 240, frameRate: { max: 10 } }
});
webcamVideo.srcObject = webcamStream;
webcamInterval = setInterval(() => {
const ctx = webcamCanvas.getContext('2d');
if (ctx) {
ctx.drawImage(webcamVideo, 0, 0, webcamCanvas.width, webcamCanvas.height);
const dataUrl = webcamCanvas.toDataURL('image/jpeg', 0.4);
window.go.main.App.SendWebcamFrame(dataUrl);
}
}, 250);
} catch (err) {
console.error('Failed to open webcam:', err);
}
}
function stopWebcam() {
if (webcamInterval) {
clearInterval(webcamInterval);
webcamInterval = null;
}
if (webcamStream) {
webcamStream.getTracks().forEach(track => track.stop());
webcamStream = null;
}
webcamVideo.srcObject = null;
}
init();

View File

@@ -0,0 +1,14 @@
html {
height: 100%;
overflow: hidden;
}
body {
margin: 0;
height: 100%;
overflow: hidden;
}
#app {
height: 100%;
}

View File

@@ -0,0 +1,14 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function CheckLoginStatus():Promise<boolean>;
export function GetStats():Promise<Record<string, any>>;
export function GetStudentInfo():Promise<Record<string, any>>;
export function Logout():Promise<void>;
export function NavigateToLogin():Promise<void>;
export function SendWebcamFrame(arg1:string):Promise<void>;

View File

@@ -0,0 +1,27 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function CheckLoginStatus() {
return window['go']['main']['App']['CheckLoginStatus']();
}
export function GetStats() {
return window['go']['main']['App']['GetStats']();
}
export function GetStudentInfo() {
return window['go']['main']['App']['GetStudentInfo']();
}
export function Logout() {
return window['go']['main']['App']['Logout']();
}
export function NavigateToLogin() {
return window['go']['main']['App']['NavigateToLogin']();
}
export function SendWebcamFrame(arg1) {
return window['go']['main']['App']['SendWebcamFrame'](arg1);
}

View File

@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}

View File

@@ -0,0 +1,330 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View File

@@ -0,0 +1,298 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}

44
client/go.mod Normal file
View File

@@ -0,0 +1,44 @@
module client
go 1.23.0
require (
github.com/gorilla/websocket v1.5.3
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
github.com/wailsapp/wails/v2 v2.12.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/gen2brain/shm v0.1.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/jezek/xgb v1.1.1 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)
// replace github.com/wailsapp/wails/v2 v2.12.0 => C:\Users\PhuocNTB\go\pkg\mod

92
client/go.sum Normal file
View File

@@ -0,0 +1,92 @@
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY=
github.com/gen2brain/shm v0.1.0/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWrS+E/7xMVpydsI48PMHcc7SfR4OxkDF4=
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,317 @@
package blocker
import (
"log"
"os"
"strings"
"sync"
"syscall"
"time"
"unsafe"
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
procEnumWindows = user32.NewProc("EnumWindows")
procIsWindowVisible = user32.NewProc("IsWindowVisible")
procGetWindowTextW = user32.NewProc("GetWindowTextW")
procGetWindowTextLengthW = user32.NewProc("GetWindowTextLengthW")
procGetWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId")
procGetWindow = user32.NewProc("GetWindow")
procGetWindowLongW = user32.NewProc("GetWindowLongW")
dwmapi = syscall.NewLazyDLL("dwmapi.dll")
procDwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute")
kernel32 = syscall.NewLazyDLL("kernel32.dll")
)
const (
GW_OWNER = 4
WS_EX_TOOLWINDOW = 0x00000080
DWMWA_CLOAKED = 14
)
func isRealGUIWindow(hwnd uintptr) bool {
// 1. Phải đang hiển thị
ret, _, _ := procIsWindowVisible.Call(hwnd)
if ret == 0 {
return false
}
// 2. Không được có chủ sở hữu (phải là cửa sổ chính - top-level)
owner, _, _ := procGetWindow.Call(hwnd, GW_OWNER)
if owner != 0 {
return false
}
// 3. Không phải là tool window (WS_EX_TOOLWINDOW)
gwlExStyle := int32(-20) // GWL_EXSTYLE = -20
style, _, _ := procGetWindowLongW.Call(hwnd, uintptr(gwlExStyle))
if (style & WS_EX_TOOLWINDOW) != 0 {
return false
}
// 4. Không bị cloaked bởi DWM (ví dụ: app UWP bị treo/chạy ngầm, màn hình ảo)
var cloaked uint32
hr, _, _ := procDwmGetWindowAttribute.Call(hwnd, DWMWA_CLOAKED, uintptr(unsafe.Pointer(&cloaked)), 4)
if hr == 0 && cloaked != 0 {
return false
}
return true
}
type WindowInfo struct {
PID uint32
Title string
ProcessName string
}
type Blocker struct {
mu sync.Mutex
allowedKeywords []string
running bool
stopChan chan struct{}
OnBlocked func(procName string, title string)
OnKill func(procName string, title string)
}
var Instance = &Blocker{
allowedKeywords: []string{"chrome", "idea64", "vscode", "wails", "simple_care", "client"},
stopChan: make(chan struct{}),
}
func getProcessMap() (map[uint32]string, error) {
snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPPROCESS, 0)
if err != nil {
return nil, err
}
defer syscall.CloseHandle(snapshot)
var pe syscall.ProcessEntry32
pe.Size = uint32(unsafe.Sizeof(pe))
err = syscall.Process32First(snapshot, &pe)
if err != nil {
return nil, err
}
pm := make(map[uint32]string)
for {
name := syscall.UTF16ToString(pe.ExeFile[:])
pm[pe.ProcessID] = name
err = syscall.Process32Next(snapshot, &pe)
if err != nil {
break
}
}
return pm, nil
}
func getWindowText(hwnd uintptr) string {
length, _, _ := procGetWindowTextLengthW.Call(hwnd)
if length == 0 {
return ""
}
buf := make([]uint16, length+1)
procGetWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), length+1)
return syscall.UTF16ToString(buf)
}
var (
enumWindowsMutex sync.Mutex
enumWindowsList []WindowInfo
enumProcessMap map[uint32]string
)
var enumWindowsCallback = syscall.NewCallback(func(hwnd uintptr, lParam uintptr) uintptr {
defer func() {
if r := recover(); r != nil {
log.Printf("[BLOCKER] Callback panic recovered: %v", r)
}
}()
if !isRealGUIWindow(hwnd) {
return 1
}
title := getWindowText(hwnd)
if title == "" {
return 1
}
var pid uint32
procGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid)))
procName := enumProcessMap[pid]
if procName == "" {
procName = "Unknown"
}
enumWindowsList = append(enumWindowsList, WindowInfo{
PID: pid,
Title: title,
ProcessName: procName,
})
return 1
})
func EnumerateGUIWindows() ([]WindowInfo, error) {
pMap, err := getProcessMap()
if err != nil {
return nil, err
}
enumWindowsMutex.Lock()
defer enumWindowsMutex.Unlock()
enumWindowsList = make([]WindowInfo, 0, 100)
enumProcessMap = pMap
procEnumWindows.Call(enumWindowsCallback, 0)
// Clean up map reference so GC can reclaim it
enumProcessMap = nil
// Copy to a new slice to return safely
res := make([]WindowInfo, len(enumWindowsList))
copy(res, enumWindowsList)
return res, nil
}
func (b *Blocker) SetKeywords(keywords string) {
b.mu.Lock()
defer b.mu.Unlock()
parts := strings.Split(keywords, ",")
var list []string
for _, p := range parts {
trimmed := strings.TrimSpace(strings.ToLower(p))
if trimmed != "" {
list = append(list, trimmed)
}
}
b.allowedKeywords = list
log.Printf("[BLOCKER] Keywords updated: %v", b.allowedKeywords)
}
var systemAllowed = map[string]bool{
"explorer.exe": true,
"taskmgr.exe": true,
"cmd.exe": true,
"powershell.exe": true,
"conhost.exe": true,
"client.exe": true, // App Wails của ta
"wails.exe": true,
"msedgewebview2.exe": true, // WebView2 runtime của Wails
"code.exe": true, // VSCode
"cursor.exe": true, // Cursor
"windsurf.exe": true, // Windsurf
"goland.exe": true, // GoLand
"goland64.exe": true, // GoLand
"idea64.exe": true, // IntelliJ IDEA
"clion64.exe": true, // CLion
"webstorm64.exe": true, // WebStorm
"pycharm64.exe": true, // PyCharm
"rider64.exe": true, // Rider
"studio64.exe": true, // Android Studio
"eclipse.exe": true, // Eclipse
"sublime_text.exe": true, // Sublime Text
"notepad++.exe": true, // Notepad++
"devenv.exe": true, // Visual Studio
"git-bash.exe": true, // Git Bash
"bash.exe": true, // Bash
}
func (b *Blocker) checkAndKill() {
b.mu.Lock()
keywords := make([]string, len(b.allowedKeywords))
copy(keywords, b.allowedKeywords)
b.mu.Unlock()
// Nếu không cấu hình keyword thì không chặn gì cả
if len(keywords) == 0 {
return
}
windows, err := EnumerateGUIWindows()
if err != nil {
return
}
for _, w := range windows {
pNameLower := strings.ToLower(w.ProcessName)
wTitleLower := strings.ToLower(w.Title)
// 1. Luôn cho phép hệ thống/app cốt lõi hoặc chính tiến trình này
if w.PID == uint32(os.Getpid()) || systemAllowed[pNameLower] || strings.Contains(pNameLower, "antigravity") || strings.Contains(wTitleLower, "antigravity") {
continue
}
// 2. Kiểm tra xem có chứa bất kỳ từ khóa nào được cho phép không
allowed := false
for _, kw := range keywords {
if strings.Contains(pNameLower, kw) || strings.Contains(wTitleLower, kw) {
allowed = true
break
}
}
// 3. Nếu không nằm trong whitelist, tắt ứng dụng
if !allowed {
if b.OnBlocked != nil {
b.OnBlocked(w.ProcessName, w.Title)
}
log.Printf("[BLOCKER] KILLED unauthorized application: %s (PID: %d, Title: %s)", w.ProcessName, w.PID, w.Title)
h, err := syscall.OpenProcess(syscall.PROCESS_TERMINATE, false, w.PID)
if err == nil {
errTerm := syscall.TerminateProcess(h, 0)
_ = syscall.CloseHandle(h)
if errTerm == nil && b.OnKill != nil {
b.OnKill(w.ProcessName, w.Title)
}
}
}
}
}
func (b *Blocker) Start() {
b.mu.Lock()
if b.running {
b.mu.Unlock()
return
}
b.running = true
b.stopChan = make(chan struct{})
b.mu.Unlock()
go func() {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
b.checkAndKill()
case <-b.stopChan:
return
}
}
}()
log.Println("[BLOCKER] Application Blocker Daemon Started.")
}
func (b *Blocker) Stop() {
b.mu.Lock()
defer b.mu.Unlock()
if !b.running {
return
}
b.running = false
close(b.stopChan)
log.Println("[BLOCKER] Application Blocker Daemon Stopped.")
}

View File

@@ -0,0 +1,35 @@
package screen
import (
"bytes"
"encoding/base64"
"fmt"
"image/jpeg"
"github.com/kbinani/screenshot"
)
// CaptureScreen chụp màn hình chính và trả về chuỗi Base64 dạng "data:image/jpeg;base64,..."
func CaptureScreen() (string, error) {
n := screenshot.NumActiveDisplays()
if n <= 0 {
return "", fmt.Errorf("no active displays found")
}
// Chụp màn hình chính (index 0)
bounds := screenshot.GetDisplayBounds(0)
img, err := screenshot.CaptureRect(bounds)
if err != nil {
return "", fmt.Errorf("failed to capture screen: %w", err)
}
var buf bytes.Buffer
// Nén chất lượng JPEG khoảng 50% để truyền tải mượt mà qua mạng
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: 50})
if err != nil {
return "", fmt.Errorf("failed to encode jpeg: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
return "data:image/jpeg;base64," + encoded, nil
}

View File

@@ -0,0 +1,82 @@
package winapi
import (
"bytes"
"os/exec"
"strings"
"syscall"
)
// WifiConnection — SSID + BSSID (MAC) của điểm phát đang kết nối
type WifiConnection struct {
SSID string
BSSID string
}
// GetWifiSSID trả về SSID Wifi đang kết nối
func GetWifiSSID() string {
return GetWifiConnection().SSID
}
// GetWifiConnection đọc SSID và BSSID từ netsh (Windows)
func GetWifiConnection() WifiConnection {
cmd := exec.Command("netsh", "wlan", "show", "interfaces")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return WifiConnection{}
}
var conn WifiConnection
for _, line := range strings.Split(out.String(), "\n") {
trimmed := strings.TrimSpace(line)
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "ssid") && !strings.Contains(lower, "bssid") {
if v := valueAfterColon(trimmed); v != "" {
conn.SSID = v
}
}
// Windows: "AP BSSID" (EN) hoặc dòng có chứa "bssid" (locale khác)
if strings.Contains(lower, "bssid") {
if v := valueAfterColon(trimmed); v != "" {
conn.BSSID = normalizeMAC(v)
}
}
}
return conn
}
func valueAfterColon(line string) string {
idx := strings.Index(line, ":")
if idx < 0 {
return ""
}
return strings.TrimSpace(line[idx+1:])
}
func normalizeMAC(mac string) string {
var hex []rune
for _, c := range strings.ToLower(mac) {
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
hex = append(hex, c)
}
}
if len(hex) != 12 {
return strings.TrimSpace(mac)
}
return string(hex[0:2]) + ":" + string(hex[2:4]) + ":" + string(hex[4:6]) + ":" +
string(hex[6:8]) + ":" + string(hex[8:10]) + ":" + string(hex[10:12])
}
// BSSIDKey chuẩn hóa MAC để so khớp (12 ký tự hex)
func BSSIDKey(bssid string) string {
var hex []rune
for _, c := range strings.ToLower(bssid) {
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
hex = append(hex, c)
}
}
return string(hex)
}

36
client/main.go Normal file
View File

@@ -0,0 +1,36 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
// Create an instance of the app structure
app := NewApp()
// Create application with options
err := wails.Run(&options.App{
Title: "Simple Care — Rikkei Education",
Width: 1024,
Height: 768,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
Bind: []interface{}{
app,
},
})
if err != nil {
println("Error:", err.Error())
}
}

13
client/wails.json Normal file
View File

@@ -0,0 +1,13 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "Simple Care by Rikkei Edu",
"outputfilename": "simple_care_v1.0",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "auto",
"author": {
"name": "PhuocNTB",
"email": "phuocntb@mieusoft.com"
}
}

View File

@@ -1,10 +1,10 @@
<!doctype html>
<html lang="en">
<html lang="vi">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>management</title>
<title>Simple Care — Rikkei Education</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,65 +1,165 @@
import { useState } from 'react';
import { useEffect } from 'react';
import { DashboardTab } from './components/DashboardTab';
import { ClassesTab } from './components/ClassesTab';
import { StudentsTab } from './components/StudentsTab';
import { LearningTab } from './components/LearningTab';
import { NetworkTab } from './components/NetworkTab';
import { ClassWorkspace } from './components/ClassWorkspace';
import { NavHistoryBar, useRoute } from './components/NavHistoryBar';
import { goBack, navigate, parseRoute, pushNav, TAB_LABELS, type TabId } from './navigation';
const IconDashboard = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
);
const IconClass = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
const IconStudent = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
const IconLearning = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
);
const IconNetwork = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
<circle cx="12" cy="20" r="1" />
</svg>
);
function App() {
const [activeTab, setActiveTab] = useState<string>('dashboard');
const route = useRoute();
useEffect(() => {
const initial = parseRoute();
if (initial.classId) {
return;
}
pushNav({ kind: 'tab', tab: initial.tab, label: TAB_LABELS[initial.tab] });
}, []);
const setActiveTab = (tab: string) => {
navigate(tab as TabId);
};
const handleBackFromClass = () => {
goBack(route.tab);
};
return (
<>
{/* Sidebar navigation panel */}
<aside className="sidebar">
<div className="brand">
<div className="brand-logo">SC</div>
<div className="brand-logo">RE</div>
<div className="brand-text">
<span className="brand-name">Simple Care</span>
<span className="brand-sub">Rikkei Education</span>
</div>
</div>
<nav>
<ul className="nav-links">
<div className="nav-header">Tổng quan</div>
<li className="nav-item">
<button
className={`nav-btn ${activeTab === 'dashboard' ? 'active' : ''}`}
onClick={() => setActiveTab('dashboard')}
className={`nav-btn ${route.tab === 'dashboard' && !route.classId ? 'active' : ''}`}
onClick={() => navigate('dashboard')}
>
<span className="nav-icon">📊</span>
Tổng Quan
<span className="nav-icon"><IconDashboard /></span>
Tổng quan
</button>
</li>
<div className="nav-header">CSDL liên kết</div>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'classes' && !route.classId ? 'active' : ''}`}
onClick={() => navigate('classes')}
>
<span className="nav-icon"><IconClass /></span>
Lớp học
</button>
</li>
<li className="nav-item">
<button
className={`nav-btn ${activeTab === 'classes' ? 'active' : ''}`}
onClick={() => setActiveTab('classes')}
className={`nav-btn ${route.tab === 'students' && !route.classId ? 'active' : ''}`}
onClick={() => navigate('students')}
>
<span className="nav-icon">🏫</span>
Lớp Học
<span className="nav-icon"><IconStudent /></span>
Sinh viên
</button>
</li>
<div className="nav-header">Quản học tập</div>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'learning' && !route.classId ? 'active' : ''}`}
onClick={() => navigate('learning')}
>
<span className="nav-icon"><IconLearning /></span>
Giám sát & Lịch học
</button>
</li>
<li className="nav-item">
<button
className={`nav-btn ${activeTab === 'students' ? 'active' : ''}`}
onClick={() => setActiveTab('students')}
className={`nav-btn ${route.tab === 'network' && !route.classId ? 'active' : ''}`}
onClick={() => navigate('network')}
>
<span className="nav-icon">🎓</span>
Sinh Viên
<span className="nav-icon"><IconNetwork /></span>
Quản mạng
</button>
</li>
</ul>
</nav>
<div className="sidebar-footer">
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Cấu hình Token:</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 500 }}>Kết nối hệ thống</div>
<div className="token-badge" title="Token QLDT_TOKEN load từ server .env">
🔑 QLDT_TOKEN (Loaded)
QLDT_TOKEN đã kết nối
</div>
</div>
</aside>
{/* Main dashboard content container */}
<main className="main-content">
{activeTab === 'dashboard' && <DashboardTab onNavigate={setActiveTab} />}
{activeTab === 'classes' && <ClassesTab />}
{activeTab === 'students' && <StudentsTab />}
<div className="page-viewport">
{route.classId ? (
<ClassWorkspace
classId={route.classId}
sourceTab={route.tab}
onBack={handleBackFromClass}
renderNavBar={(className) => (
<NavHistoryBar classLabel={className} onBack={handleBackFromClass} />
)}
/>
) : (
<>
{route.tab === 'dashboard' && <DashboardTab onNavigate={setActiveTab} />}
{route.tab === 'classes' && <ClassesTab />}
{route.tab === 'students' && <StudentsTab />}
{route.tab === 'learning' && <LearningTab />}
{route.tab === 'network' && <NetworkTab />}
</>
)}
</div>
</main>
</>
);

View File

@@ -28,6 +28,7 @@ export interface StudentItem {
location?: string;
systemId?: number;
systemName?: string;
avatar?: string;
}
export interface StatsResponse {
@@ -54,6 +55,55 @@ export interface SyncStatus {
pageSize?: number;
}
export interface ClassScheduleItem {
id?: number;
classRkId?: number;
dayOfWeek: number;
period: number;
startTime: string;
endTime: string;
courseId: number;
courseName: string;
isActive: boolean;
}
export interface ClassCourseItem {
id: number;
name: string;
courseCode?: string;
}
export interface AttendanceRow {
studentRkId: number;
studentCode: string;
fullName: string;
email: string;
status: number;
statusLabel: string;
onlineMinutes: number;
statusEditedByTeacher: boolean;
}
export const ATTENDANCE_STATUS_OPTIONS = [
{ value: 0, label: 'Nghỉ không phép' },
{ value: 1, label: 'Nghỉ có phép' },
{ value: 2, label: 'Nghỉ nửa buổi' },
{ value: 3, label: 'Đi học muộn' },
{ value: 4, label: 'Đi học đầy đủ' },
];
export interface StudentSessionLogItem {
id: number;
studentRkId: number;
studentCode: string;
fullName: string;
email: string;
onlineSeconds: number;
offlineSeconds: number;
wifiSsids: string;
lastActiveAt?: string;
}
export const api = {
getStats: async (): Promise<StatsResponse> => {
const res = await fetch(`${API_BASE}/stats`);
@@ -87,7 +137,10 @@ export const api = {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isStudying }),
});
if (!res.ok) throw new Error('Failed to update class studying status');
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to update class studying status');
}
return res.json();
},
@@ -143,3 +196,199 @@ export const api = {
return res.json();
},
};
// Export individual learning functions to simplify imports in components
export const apiFetchActiveSchedules = async (): Promise<{ data: any[] }> => {
const res = await fetch(`${API_BASE}/classes/schedules`);
if (!res.ok) throw new Error('Failed to fetch active schedules');
return res.json();
};
export const apiFetchClassSchedule = async (rkId: number): Promise<{ data: ClassScheduleItem[] }> => {
const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`);
if (!res.ok) throw new Error('Failed to fetch class schedule');
return res.json();
};
export const apiSaveClassSchedule = async (rkId: number, schedules: ClassScheduleItem[]): Promise<any> => {
const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schedules }),
});
if (!res.ok) throw new Error('Failed to save class schedule');
return res.json();
};
export const apiDeleteClassSchedule = async (rkId: number): Promise<any> => {
const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete class schedule');
return res.json();
};
export const apiFetchAllowedApps = async (rkId: number): Promise<{ classRkId: number; keywords: string }> => {
const res = await fetch(`${API_BASE}/classes/${rkId}/allowed-apps`);
if (!res.ok) throw new Error('Failed to fetch allowed apps keywords');
return res.json();
};
export const apiSaveAllowedApps = async (rkId: number, keywords: string): Promise<any> => {
const res = await fetch(`${API_BASE}/classes/${rkId}/allowed-apps`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keywords }),
});
if (!res.ok) throw new Error('Failed to save allowed apps keywords');
return res.json();
};
export interface AppPoolItem {
id: number;
processName: string;
windowTitle: string;
keyword: string;
hitCount: number;
lastSeenAt: string;
lastStudentRkId?: number;
lastClassRkId?: number;
}
export const apiFetchAppPool = async (q = '', limit = 50): Promise<{ data: AppPoolItem[]; q?: string; limit?: number }> => {
const params = new URLSearchParams();
if (q.trim()) params.set('q', q.trim());
params.set('limit', String(limit));
const query = params.toString() ? `?${params.toString()}` : '';
const res = await fetch(`${API_BASE}/app-pool${query}`);
if (!res.ok) throw new Error('Failed to fetch app pool');
return res.json();
};
export interface WifiPoolItem {
id: number;
ssid: string;
bssid: string;
hitCount: number;
lastSeenAt: string;
lastStudentRkId?: number;
}
export interface AcceptedWifiItem {
id: number;
ssid: string;
bssid: string;
createdAt: string;
}
export type WifiAcceptItem = { ssid: string; bssid: string };
export const apiFetchWifiPool = async (q = '', limit = 50): Promise<{ data: WifiPoolItem[] }> => {
const params = new URLSearchParams();
if (q.trim()) params.set('q', q.trim());
params.set('limit', String(limit));
const query = params.toString() ? `?${params.toString()}` : '';
const res = await fetch(`${API_BASE}/wifi-pool${query}`);
if (!res.ok) throw new Error('Failed to fetch wifi pool');
return res.json();
};
export const apiFetchAcceptedWifis = async (): Promise<{ data: AcceptedWifiItem[] }> => {
const res = await fetch(`${API_BASE}/network/accepted-wifis`);
if (!res.ok) throw new Error('Failed to fetch accepted wifis');
return res.json();
};
export const apiSaveAcceptedWifis = async (items: WifiAcceptItem[]): Promise<any> => {
const res = await fetch(`${API_BASE}/network/accepted-wifis`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
});
if (!res.ok) throw new Error('Failed to save accepted wifis');
return res.json();
};
export const apiFetchClassStudents = api.getClassStudents;
export const apiFetchClasses = api.getClasses;
export const apiFetchClassSessionLogs = async (
rkId: number,
date?: string,
period?: number
): Promise<{ data: StudentSessionLogItem[]; period?: number; date?: string }> => {
const params = new URLSearchParams();
if (date) params.set('date', date);
if (period) params.set('period', String(period));
const query = params.toString() ? `?${params.toString()}` : '';
const res = await fetch(`${API_BASE}/classes/${rkId}/session-logs${query}`);
if (!res.ok) throw new Error('Failed to fetch class session logs');
return res.json();
};
export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => {
const res = await fetch(`${API_BASE}/classes/${rkId}/online-students`);
if (!res.ok) throw new Error('Failed to fetch online students list');
return res.json();
};
export const apiFetchClassCourses = async (rkId: number): Promise<{
data: ClassCourseItem[];
source?: string;
cached?: boolean;
warning?: string;
hint?: string;
}> => {
const res = await fetch(`${API_BASE}/classes/${rkId}/courses`);
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body.error || body.hint || 'Không tải được danh sách môn học từ QLĐT');
}
return body;
};
export const apiApplyScheduleTemplate = async (rkId: number): Promise<{ created: number; skipped: number }> => {
const res = await fetch(`${API_BASE}/classes/${rkId}/schedule/apply-template`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to apply schedule template');
return res.json();
};
export const apiFetchAttendanceShifts = async (rkId: number, date?: string) => {
const q = date ? `?date=${date}` : '';
const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/shifts${q}`);
if (!res.ok) throw new Error('Failed to fetch attendance shifts');
return res.json();
};
export const apiFetchAttendance = async (rkId: number, date: string, period: number) => {
const res = await fetch(`${API_BASE}/classes/${rkId}/attendance?date=${date}&period=${period}`);
if (!res.ok) throw new Error('Failed to fetch attendance');
return res.json();
};
export const apiUpdateAttendanceStatus = async (
rkId: number,
payload: { date: string; period: number; studentRkId: number; status: number }
) => {
const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Failed to update attendance status');
}
return res.json();
};
export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => {
const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/push-qldt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date, period }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Failed to push attendance to QLĐT');
}
return res.json();
};

View File

@@ -0,0 +1,167 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { apiFetchAppPool } from '../api';
import type { AppPoolItem } from '../api';
interface AppPoolModalProps {
open: boolean;
onClose: () => void;
onSelect: (keyword: string) => void;
allowedApps: string;
}
const formatWhen = (iso: string) => {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
};
export const AppPoolModal: React.FC<AppPoolModalProps> = ({
open,
onClose,
onSelect,
allowedApps,
}) => {
const [search, setSearch] = useState('');
const [debouncedQ, setDebouncedQ] = useState('');
const [items, setItems] = useState<AppPoolItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const allowedSet = useMemo(() => {
return new Set(
allowedApps.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
);
}, [allowedApps]);
useEffect(() => {
if (!open) return;
const t = setTimeout(() => setDebouncedQ(search.trim()), 300);
return () => clearTimeout(t);
}, [search, open]);
const loadPool = useCallback(async (q: string) => {
try {
setLoading(true);
setError(null);
const res = await apiFetchAppPool(q, 80);
setItems(res.data || []);
} catch (e: any) {
setItems([]);
setError(e?.message || 'Không tải được kho ứng dụng');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
loadPool(debouncedQ);
const interval = setInterval(() => loadPool(debouncedQ), 20000);
return () => clearInterval(interval);
}, [open, debouncedQ, loadPool]);
useEffect(() => {
if (!open) {
setSearch('');
setDebouncedQ('');
setItems([]);
setError(null);
}
}, [open]);
if (!open) return null;
const handleSelect = (keyword: string) => {
const kw = keyword.trim().toLowerCase();
if (!kw || allowedSet.has(kw)) return;
onSelect(kw);
};
return (
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
<div className="modal-container app-pool-modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div>
<h2 className="modal-title" style={{ margin: 0 }}>Kho ng dụng</h2>
<p className="app-pool-modal-sub">
Toàn hệ thống app bị chặn từ mọi lớp. Chọn đ thêm vào whitelist lớp hiện tại.
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={onClose}>Đóng</button>
</div>
<div className="app-pool-toolbar">
<input
type="search"
className="app-pool-search"
placeholder="Tìm theo tên app, keyword, tiêu đề cửa sổ..."
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
<button
type="button"
className="btn btn-secondary"
onClick={() => loadPool(debouncedQ)}
disabled={loading}
>
{loading ? 'Đang tải...' : 'Làm mới'}
</button>
</div>
<div className="app-pool-body">
{error ? (
<div className="discovered-apps-empty discovered-apps-error">{error}</div>
) : loading && items.length === 0 ? (
<div className="app-pool-status">Đang tải...</div>
) : items.length === 0 ? (
<div className="app-pool-status">
{debouncedQ ? `Không tìm thấy "${debouncedQ}"` : 'Chưa có app nào trong kho.'}
</div>
) : (
<table className="data-table app-pool-table">
<thead>
<tr>
<th>Keyword</th>
<th>Tiến trình</th>
<th>Tiêu đ</th>
<th>Lần chặn</th>
<th>Gần nhất</th>
<th></th>
</tr>
</thead>
<tbody>
{items.map(app => {
const added = allowedSet.has(app.keyword.toLowerCase());
return (
<tr key={app.id} className={added ? 'app-pool-row--added' : ''}>
<td><code className="app-pool-kw">{app.keyword}</code></td>
<td className="app-pool-muted">{app.processName}</td>
<td className="app-pool-muted app-pool-title" title={app.windowTitle}>{app.windowTitle || '—'}</td>
<td className="app-pool-hit">{app.hitCount}×</td>
<td className="app-pool-muted">{formatWhen(app.lastSeenAt)}</td>
<td>
{added ? (
<span className="app-pool-added-tag">Đã </span>
) : (
<button type="button" className="btn btn-primary app-pool-add-btn" onClick={() => handleSelect(app.keyword)}>
+ Thêm
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
<div className="app-pool-footer">
Hiển thị {items.length} kết quả{debouncedQ ? ` cho "${debouncedQ}"` : ''} (tối đa 80 mỗi lần tải)
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,163 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
ATTENDANCE_STATUS_OPTIONS,
apiFetchAttendance,
apiFetchAttendanceShifts,
apiPushAttendanceQLDT,
apiUpdateAttendanceStatus,
type AttendanceRow,
} from '../api';
interface AttendancePanelProps {
classId: number;
}
export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) => {
const today = new Date().toISOString().slice(0, 10);
const [date, setDate] = useState(today);
const [period, setPeriod] = useState(1);
const [rows, setRows] = useState<AttendanceRow[]>([]);
const [shifts, setShifts] = useState<any[]>([]);
const [shiftInfo, setShiftInfo] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [pushing, setPushing] = useState(false);
const loadShifts = useCallback(async () => {
try {
const res = await apiFetchAttendanceShifts(classId, date);
setShifts(res.data || []);
if (res.data?.length && !res.data.find((s: any) => s.period === period)) {
setPeriod(res.data[0].period || 1);
}
} catch {
setShifts([]);
}
}, [classId, date, period]);
const loadAttendance = useCallback(async () => {
try {
setLoading(true);
const res = await apiFetchAttendance(classId, date, period);
setRows(res.data || []);
setShiftInfo(res.shift || null);
} catch (e: any) {
alert(e.message || 'Không tải được điểm danh');
} finally {
setLoading(false);
}
}, [classId, date, period]);
useEffect(() => { loadShifts(); }, [loadShifts]);
useEffect(() => { loadAttendance(); }, [loadAttendance]);
const handleStatusChange = async (studentRkId: number, status: number) => {
try {
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status });
await loadAttendance();
} catch (e: any) {
alert(e.message || 'Cập nhật trạng thái thất bại');
}
};
const handlePushQLDT = async () => {
if (!shiftInfo?.courseId) {
alert('Ca học này chưa chọn môn học. Vui lòng cấu hình trong lịch học.');
return;
}
if (!confirm(`Đẩy điểm danh Ca ${period} ngày ${date} lên QLĐT?`)) return;
try {
setPushing(true);
const res = await apiPushAttendanceQLDT(classId, date, period);
alert(res.message || 'Đã đẩy lên QLĐT');
} catch (e: any) {
alert(e.message || 'Đẩy QLĐT thất bại');
} finally {
setPushing(false);
}
};
const currentShift = shifts.find(s => s.period === period);
return (
<div className="attendance-panel">
<div className="attendance-toolbar">
<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>Ca học</span>
<select className="select-filter" value={period} onChange={e => setPeriod(Number(e.target.value))}>
{(shifts.length ? shifts : [{ period: 1 }, { period: 2 }, { period: 3 }, { period: 4 }]).map((s: any) => (
<option key={s.period} value={s.period}>
Ca {s.period}{s.startTime ? ` (${s.startTime}${s.endTime})` : ''}
</option>
))}
</select>
</label>
<button type="button" className="btn btn-secondary" onClick={loadAttendance} disabled={loading}>Tải lại</button>
<button type="button" className="btn btn-primary" onClick={handlePushQLDT} disabled={pushing}>
{pushing ? 'Đang đẩy...' : 'Đẩy QLĐT'}
</button>
</div>
{currentShift && (
<div className="attendance-shift-info">
<span>{currentShift.startTime}{currentShift.endTime}</span>
<span>{currentShift.courseName || 'Chưa chọn môn'}</span>
{!currentShift.isActive && <span className="badge badge-muted">Ca tắt</span>}
</div>
)}
<p className="schedule-hint" style={{ margin: '0.25rem 0 0.5rem' }}>
Sửa trạng thái thủ công sẽ đưc khóa hệ thống tự tính sẽ không ghi đè.
</p>
<div className="attendance-table-scroll table-wrapper">
{loading ? (
<div className="empty-state"><div className="sync-spinner" style={{ width: 28, height: 28 }} /></div>
) : (
<table className="data-table">
<thead>
<tr>
<th>Sinh viên</th>
<th>Online (phút)</th>
<th>Trạng thái</th>
<th>Ghi chú</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>Chưa dữ liệu điểm danh</td></tr>
) : rows.map(row => (
<tr key={row.studentRkId}>
<td>
<div style={{ fontWeight: 600 }}>{row.fullName}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{row.studentCode}</div>
</td>
<td style={{ fontFamily: 'monospace' }}>{row.onlineMinutes}</td>
<td>
<select
className="select-filter"
value={row.status}
onChange={e => handleStatusChange(row.studentRkId, Number(e.target.value))}
>
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</td>
<td>
{row.statusEditedByTeacher && (
<span className="badge badge-info" title="Giáo viên đã sửa — không bị ghi đè">Đã khóa</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,547 @@
import React, { useEffect, useState } from 'react';
import {
api,
apiSaveAllowedApps,
apiFetchClassStudents,
apiFetchOnlineStudents,
apiFetchClassSessionLogs,
apiFetchClassSchedule,
apiFetchAllowedApps,
apiFetchAttendanceShifts,
} from '../api';
import type {
ClassItem,
StudentItem,
ClassScheduleItem,
StudentSessionLogItem,
} from '../api';
import { BASE_APP_SUGGESTIONS } from '../constants';
import { pushNav, type TabId } from '../navigation';
import { ScheduleEditor } from './ScheduleEditor';
import { AttendancePanel } from './AttendancePanel';
import { StudentAvatar } from './StudentAvatar';
import { StudentDetailModal } from './StudentDetailModal';
import { AppPoolModal } from './AppPoolModal';
interface ClassWorkspaceProps {
classId: number;
sourceTab?: TabId;
onBack?: () => void;
renderNavBar?: (className: string) => React.ReactNode;
}
export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceTab = 'classes', renderNavBar }) => {
const [classInfo, setClassInfo] = useState<ClassItem | null>(null);
const [students, setStudents] = useState<StudentItem[]>([]);
const [onlineIds, setOnlineIds] = useState<number[]>([]);
const [allowedApps, setAllowedApps] = useState('');
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
const [sessionLogs, setSessionLogs] = useState<StudentSessionLogItem[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const today = new Date().toISOString().slice(0, 10);
const [logDate, setLogDate] = useState(today);
const [logPeriod, setLogPeriod] = useState(1);
const [logShifts, setLogShifts] = useState<any[]>([]);
const [selectedStudent, setSelectedStudent] = useState<StudentItem | null>(null);
// Loading & tab states
const [loading, setLoading] = useState(true);
const [logsLoading, setLogsLoading] = useState(false);
const [savingApps, setSavingApps] = useState(false);
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'logs' | 'attendance'>('roster');
const [configOpen, setConfigOpen] = useState(false);
const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
const [appPoolOpen, setAppPoolOpen] = useState(false);
const loadAllData = async () => {
try {
setLoading(true);
// Fetch specific class detail directly
const targetClassRes = await fetch(`http://127.0.0.1:8080/api/classes/${classId}`);
if (targetClassRes.ok) {
const classData = await targetClassRes.json();
setClassInfo(classData);
pushNav({
kind: 'class',
tab: sourceTab,
classId,
label: classData.name || `Lớp ${classId}`,
});
}
// Fetch students roster
const studentsRes = await apiFetchClassStudents(classId);
setStudents(studentsRes.data);
// Fetch allowed apps
try {
const appsRes = await apiFetchAllowedApps(classId);
setAllowedApps(appsRes.keywords || '');
} catch (e) {
console.log("No allowed apps configured yet");
}
// Fetch schedules
try {
const schedRes = await apiFetchClassSchedule(classId);
setSchedules(schedRes.data || []);
} catch (e) {
console.log("No schedule configured yet");
}
// Fetch online status
await fetchOnlineStatus();
// Fetch logs
await fetchLogs();
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const fetchOnlineStatus = async () => {
try {
const res = await apiFetchOnlineStudents(classId);
setOnlineIds(res.onlineStudentIds || []);
} catch (e) {
console.error("Failed to load online students list", e);
}
};
const fetchLogs = async (date = logDate, period = logPeriod) => {
try {
setLogsLoading(true);
const res = await apiFetchClassSessionLogs(classId, date, period);
setSessionLogs(res.data || []);
if (res.period) setLogPeriod(res.period);
} catch (e) {
console.error("Failed to load session logs", e);
} finally {
setLogsLoading(false);
}
};
const loadLogShifts = async (date = logDate) => {
try {
const res = await apiFetchAttendanceShifts(classId, date);
setLogShifts(res.data || []);
} catch {
setLogShifts([]);
}
};
useEffect(() => {
loadAllData();
}, [classId]);
useEffect(() => {
const interval = setInterval(() => {
fetchOnlineStatus();
fetchLogs(logDate, logPeriod);
}, 5000);
return () => clearInterval(interval);
}, [classId, logDate, logPeriod]);
useEffect(() => {
loadLogShifts(logDate);
if (activeSubTab === 'logs') {
fetchLogs(logDate, logPeriod);
}
}, [activeSubTab, logDate, logPeriod, classId]);
useEffect(() => {
fetchLogs(logDate, logPeriod);
loadLogShifts(logDate);
}, [classId]);
const handleToggleStudying = async () => {
if (!classInfo) return;
const nextVal = !classInfo.isStudying;
try {
setClassInfo(prev => prev ? { ...prev, isStudying: nextVal } : null);
await api.updateClassStudying(classId, nextVal);
} catch (err: any) {
setClassInfo(prev => prev ? { ...prev, isStudying: !nextVal } : null);
alert(err.message || 'Không thể cập nhật trạng thái lớp học');
}
};
const addAppKeyword = (kw: string) => {
const trimmed = kw.trim().toLowerCase();
if (!trimmed) return;
setAllowedApps(prev => {
const parts = prev.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
if (parts.includes(trimmed)) return prev;
return prev ? `${prev}, ${trimmed}` : trimmed;
});
};
const handleSaveAllowedApps = async () => {
try {
setSavingApps(true);
await apiSaveAllowedApps(classId, allowedApps);
alert('Đã lưu cấu hình ứng dụng được phép thành công!');
} catch (e: any) {
alert(e.message || 'Lỗi khi lưu cấu hình ứng dụng');
} finally {
setSavingApps(false);
}
};
const reloadSchedules = async () => {
try {
const schedRes = await apiFetchClassSchedule(classId);
setSchedules(schedRes.data || []);
} catch {
setSchedules([]);
}
};
const filteredStudents = students.filter(st => {
const term = searchQuery.toLowerCase().trim();
if (!term) return true;
return (
st.fullName.toLowerCase().includes(term) ||
st.studentCode.toLowerCase().includes(term) ||
st.email.toLowerCase().includes(term) ||
(st.phone && st.phone.includes(term))
);
});
const formatDuration = (totalSeconds: number) => {
const hrs = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
const pad = (num: number) => String(num).padStart(2, '0');
return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
};
if (loading) {
return (
<div className="loading-screen">
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }}></div>
<div style={{ fontWeight: 600, fontSize: '0.95rem', color: 'var(--text-secondary)' }}>Đang tải không gian làm việc của lớp...</div>
</div>
);
}
if (!classInfo) {
return (
<div className="loading-screen">
<div className="empty-state-icon">🏫</div>
<div style={{ fontWeight: 700, fontSize: '1.15rem', color: 'var(--danger)' }}>Không tìm thấy thông tin lớp học</div>
<button className="btn btn-secondary" onClick={() => window.close()}>Đóng tab</button>
</div>
);
}
const onlineCount = students.filter(st => onlineIds.includes(st.rkId)).length;
const getSessionLogForStudent = (rkId: number) =>
sessionLogs.find(log => log.studentRkId === rkId) || null;
return (
<div className="workspace-layout workspace-embedded">
{renderNavBar && classInfo && renderNavBar(classInfo.name)}
{/* Header section */}
<div className="workspace-header">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div className="brand-logo" style={{ width: 44, height: 44, fontSize: '1.1rem' }}>🏫</div>
<div>
<h1 className="workspace-class-title">{classInfo.name}</h1>
<div className="class-badge-container">
<span className="badge badge-muted" style={{ fontFamily: 'monospace' }}>CODE: {classInfo.classCode}</span>
<span className="badge badge-info">ID: {classInfo.rkId}</span>
<span className="badge badge-muted">{classInfo.systemName || 'Hệ thống'}</span>
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1.25rem', flexWrap: 'wrap' }}>
<div className="status-panel">
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>Trạng thái dạy</span>
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: classInfo.isStudying ? 'var(--success)' : 'var(--text-muted)' }}>
{classInfo.isStudying ? 'Đang học' : 'Tạm dừng'}
</span>
</div>
<label className="switch">
<input type="checkbox" checked={classInfo.isStudying} onChange={handleToggleStudying} />
<span className="slider"></span>
</label>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>Online / số</div>
<div className="workspace-stat-value">
<span style={{ color: 'var(--success)' }}>{onlineCount}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem', fontWeight: 500 }}> / {students.length} SV</span>
</div>
</div>
</div>
</div>
{/* Main area */}
<div className="workspace-main">
{configOpen && (
<div className="workspace-drawer-backdrop" onClick={() => setConfigOpen(false)} aria-hidden />
)}
<aside className={`workspace-config-drawer ${configOpen ? 'open' : ''}`}>
<div className="workspace-drawer-header">
<strong>Cấu hình lớp</strong>
<button type="button" className="btn btn-secondary workspace-drawer-close" onClick={() => setConfigOpen(false)} aria-label="Đóng">×</button>
</div>
<div className="workspace-drawer-tabs">
<button type="button" className={`workspace-drawer-tab ${configTab === 'apps' ? 'active' : ''}`} onClick={() => setConfigTab('apps')}>
ng dụng
</button>
<button type="button" className={`workspace-drawer-tab ${configTab === 'schedule' ? 'active' : ''}`} onClick={() => setConfigTab('schedule')}>
Lịch học
</button>
</div>
<div className="workspace-drawer-body">
{configTab === 'apps' ? (
<div className="config-card config-card-flat">
<div className="card-header-title">ng dụng đưc phép</div>
<p className="config-card-desc">
Từ khóa tiến trình hoặc tiêu đ đưc phép (ngăn cách bằng dấu phẩy).
</p>
<textarea
className="app-textarea"
placeholder="chrome, vscode, cursor, goland, client, zoom"
value={allowedApps}
onChange={e => setAllowedApps(e.target.value)}
/>
<div className="config-suggestions">
<span className="config-suggestions-label">Gợi ý nhanh:</span>
<div className="config-suggestions-list">
{BASE_APP_SUGGESTIONS.map(kw => (
<span key={kw} className="app-suggestion-badge" onClick={() => addAppKeyword(kw)}>
+ {kw}
</span>
))}
</div>
</div>
<div className="app-pool-open-row">
<div>
<span className="config-suggestions-label">Kho ng dụng (toàn hệ thống)</span>
<p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}>
App bị chặn từ mọi lớp mở kho đ tìm thêm nhanh.
</p>
</div>
<button
type="button"
className="btn btn-secondary app-pool-open-btn"
onClick={() => setAppPoolOpen(true)}
>
Mở kho & tìm kiếm
</button>
</div>
<button
className="btn btn-primary"
style={{ width: '100%', justifyContent: 'center' }}
onClick={handleSaveAllowedApps}
disabled={savingApps}
>
{savingApps ? 'Đang lưu...' : 'Lưu cấu hình apps'}
</button>
</div>
) : (
<div className="config-card config-card-flat">
<div className="card-header-title">Lịch học trong tuần</div>
<p className="config-card-desc">Tối đa 4 ca/ngày. Chọn môn từ QLĐT cho từng ca.</p>
<ScheduleEditor classId={classId} schedules={schedules} onSaved={reloadSchedules} />
</div>
)}
</div>
</aside>
<div className="right-panel workspace-content-panel">
<div className="workspace-panel-toolbar">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
<button
type="button"
className={`btn btn-secondary workspace-config-toggle ${configOpen ? 'active' : ''}`}
onClick={() => setConfigOpen(v => !v)}
>
{configOpen ? 'Ẩn cấu hình' : 'Cấu hình lớp'}
</button>
<div className="tab-btn-group">
<button
className={`tab-sub-btn ${activeSubTab === 'roster' ? 'active' : ''}`}
onClick={() => setActiveSubTab('roster')}
>
đ học viên
</button>
<button
className={`tab-sub-btn ${activeSubTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveSubTab('logs')}
>
Nhật theo ca
</button>
<button
className={`tab-sub-btn ${activeSubTab === 'attendance' ? 'active' : ''}`}
onClick={() => setActiveSubTab('attendance')}
>
Điểm danh theo ca
</button>
</div>
</div>
{/* Student Search */}
{activeSubTab !== 'attendance' && <div className="search-input-wrapper">
<input
type="text"
className="search-input"
placeholder="Tìm học viên..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
<span className="search-icon">🔍</span>
</div>}
</div>
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }}></div>
</div>
<div className={`workspace-panel-body ${activeSubTab === 'attendance' || activeSubTab === 'logs' ? 'workspace-panel-fill' : ''}`}>
{activeSubTab === 'attendance' ? (
<AttendancePanel classId={classId} />
) : activeSubTab === 'roster' ? (
/* Student Roster Grid */
filteredStudents.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}>
<div className="empty-state-icon">👤</div>
<h2>Không tìm thấy học viên nào</h2>
<p>Hãy thử tìm kiếm với từ khóa khác hoặc kiểm tra lại danh sách lớp.</p>
</div>
) : (
<div className="student-status-grid">
{filteredStudents.map(st => {
const isOnline = onlineIds.includes(st.rkId);
return (
<div
key={st.rkId}
className={`student-status-card student-status-card-clickable ${isOnline ? 'online' : 'offline'}`}
onClick={() => setSelectedStudent(st)}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setSelectedStudent(st); }}
>
<StudentAvatar fullName={st.fullName} avatar={st.avatar} isOnline={isOnline} size={52} />
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{st.fullName}
</span>
<span style={{ fontSize: '0.78rem', color: 'var(--accent)', fontFamily: 'monospace', fontWeight: 600 }}>
{st.studentCode}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', marginTop: '0.4rem' }}>
<span className={isOnline ? 'pulse-dot-online' : 'status-badge-offline'}></span>
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: isOnline ? 'var(--success)' : 'var(--text-muted)' }}>
{isOnline ? 'ONLINE' : 'OFFLINE'}
</span>
</div>
</div>
</div>
);
})}
</div>
)
) : (
<div className="session-logs-panel">
<div className="attendance-toolbar" style={{ marginBottom: '0.5rem' }}>
<label className="attendance-field">
<span>Ngày</span>
<input type="date" className="search-input" style={{ padding: '0.5rem 0.75rem' }} value={logDate} onChange={e => setLogDate(e.target.value)} />
</label>
<label className="attendance-field">
<span>Ca học</span>
<select className="select-filter" value={logPeriod} onChange={e => setLogPeriod(Number(e.target.value))}>
{(logShifts.length ? logShifts : [{ period: 1 }, { period: 2 }, { period: 3 }, { period: 4 }]).map((s: any) => (
<option key={s.period} value={s.period}>
Ca {s.period}{s.startTime ? ` (${s.startTime}${s.endTime})` : ''}
</option>
))}
</select>
</label>
</div>
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
{logsLoading ? (
<div className="empty-state" style={{ minHeight: '300px' }}>
<div className="sync-spinner" style={{ width: '32px', height: '32px' }}></div>
<p style={{ marginTop: '0.5rem' }}>Đang tải nhật điểm danh học tập...</p>
</div>
) : sessionLogs.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}>
<div className="empty-state-icon">📊</div>
<h2>Chưa nhật Ca {logPeriod}</h2>
<p>Hệ thống ghi nhận theo từng ca khi học viên mở app trong khung giờ học.</p>
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th>Học viên</th>
<th>Thời gian Online</th>
<th>Thời gian Offline</th>
<th>WiFi SSID</th>
<th>Hoạt đng cuối</th>
</tr>
</thead>
<tbody>
{sessionLogs.map(logItem => (
<tr key={logItem.studentRkId}>
<td>
<div style={{ fontWeight: 600, color: 'var(--text-primary)' }}>{logItem.fullName}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{logItem.studentCode} | {logItem.email}</div>
</td>
<td style={{ color: 'var(--success)', fontWeight: 700, fontFamily: 'monospace' }}>
{formatDuration(logItem.onlineSeconds)}
</td>
<td style={{ color: 'var(--danger)', fontWeight: 700, fontFamily: 'monospace' }}>
{formatDuration(logItem.offlineSeconds)}
</td>
<td style={{ fontSize: '0.85rem', fontFamily: 'monospace' }}>📶 {logItem.wifiSsids}</td>
<td style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
{logItem.lastActiveAt ? new Date(logItem.lastActiveAt).toLocaleTimeString() : '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
</div>
</div>
{selectedStudent && (
<StudentDetailModal
student={selectedStudent}
isOnline={onlineIds.includes(selectedStudent.rkId)}
sessionLog={getSessionLogForStudent(selectedStudent.rkId)}
onClose={() => setSelectedStudent(null)}
/>
)}
<AppPoolModal
open={appPoolOpen}
onClose={() => setAppPoolOpen(false)}
onSelect={addAppKeyword}
allowedApps={allowedApps}
/>
</div>
);
};

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { api } from '../api';
import type { ClassItem, StudentItem, SyncStatus } from '../api';
import type { ClassItem, SyncStatus } from '../api';
import { openClass } from './NavHistoryBar';
export const ClassesTab: React.FC = () => {
const [classes, setClasses] = useState<ClassItem[]>([]);
@@ -9,17 +10,11 @@ export const ClassesTab: React.FC = () => {
const [pageSize] = useState(15);
const [search, setSearch] = useState('');
const [systemFilter, setSystemFilter] = useState<number | undefined>(undefined);
const [studyingOnly, setStudyingOnly] = useState(false);
const [studyingOnly, setStudyingOnly] = useState(true);
const [loading, setLoading] = useState(true);
// Sync state
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
// Modal detail class
const [activeClass, setActiveClass] = useState<ClassItem | null>(null);
const [modalStudents, setModalStudents] = useState<StudentItem[]>([]);
const [modalLoading, setModalLoading] = useState(false);
const fetchClasses = async () => {
try {
setLoading(true);
@@ -98,24 +93,10 @@ export const ClassesTab: React.FC = () => {
// Optimistic update
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: nextVal } : c));
await api.updateClassStudying(cItem.rkId, nextVal);
} catch (err) {
} catch (err: any) {
// Revert if error
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: !nextVal } : c));
alert('Không thể cập nhật trạng thái lớp học');
}
};
const handleOpenStudentsModal = async (cItem: ClassItem) => {
setActiveClass(cItem);
setModalStudents([]);
try {
setModalLoading(true);
const res = await api.getClassStudents(cItem.rkId);
setModalStudents(res.data);
} catch (err) {
alert('Không thể tải danh sách học sinh của lớp');
} finally {
setModalLoading(false);
alert(err.message || 'Không thể cập nhật trạng thái lớp học');
}
};
@@ -124,8 +105,11 @@ export const ClassesTab: React.FC = () => {
? Math.round((syncStatus.synced / syncStatus.total) * 100)
: 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div className="tab-page">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1>Quản Lớp Học</h1>
@@ -221,9 +205,11 @@ export const ClassesTab: React.FC = () => {
</label>
</div>
</div>
</div>
{/* Classes list table */}
<div className="table-wrapper">
<div className="tab-page-body">
<div className="table-wrapper table-fill">
{loading ? (
<div className="empty-state">
<div className="sync-spinner" style={{ width: '32px', height: '32px' }}></div>
@@ -250,20 +236,26 @@ export const ClassesTab: React.FC = () => {
</thead>
<tbody>
{classes.map(cl => (
<tr key={cl.rkId}>
<tr key={cl.rkId} style={cl.isStudying ? { background: 'rgba(16, 185, 129, 0.02)' } : {}}>
<td>
<div style={{ fontWeight: 600 }}>{cl.classCode}</div>
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{cl.classCode}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {cl.rkId}</div>
</td>
<td>
<div style={{ fontWeight: 500, color: 'white' }}>{cl.name}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
{cl.specializeName || '—'}
<div
style={{ fontWeight: 600, color: 'var(--accent)', fontSize: '0.95rem', cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => openClass('classes', cl.rkId, cl.name)}
title="Mở Không gian làm việc của lớp"
>
{cl.name}
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.15rem' }}>
💼 {cl.specializeName || 'Chưa có chuyên ngành'}
</div>
</td>
<td>
<span className="badge badge-muted">
{cl.systemName || `Hệ thống ${cl.systemRkId || 'Khác'}`}
<span className="badge badge-muted" style={{ fontWeight: 600 }}>
📍 {cl.systemName || `Hệ thống ${cl.systemRkId || 'Khác'}`}
</span>
</td>
<td>
@@ -280,11 +272,12 @@ export const ClassesTab: React.FC = () => {
</div>
</td>
<td>
<span className="badge badge-info">
👤 {cl.studentCount} sinh viên
<span className="badge badge-info" style={{ padding: '0.3rem 0.65rem', borderRadius: '6px', fontWeight: 600 }}>
👤 {cl.studentCount} SV
</span>
</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<div className="switch-container">
<label className="switch">
<input
@@ -295,14 +288,23 @@ export const ClassesTab: React.FC = () => {
<span className="slider"></span>
</label>
</div>
{cl.isStudying ? (
<span style={{ color: 'var(--success)', fontSize: '0.72rem', display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontWeight: 700, textTransform: 'uppercase' }}>
<span className="pulse-dot-active" style={{ display: 'inline-block', width: '6px', height: '6px', background: '#10b981', borderRadius: '50%', boxShadow: '0 0 6px #10b981' }}></span>
Đang học
</span>
) : (
<span style={{ color: 'var(--text-muted)', fontSize: '0.72rem', textTransform: 'uppercase', fontWeight: 600 }}>Tạm dừng</span>
)}
</div>
</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-secondary"
style={{ padding: '0.45rem 0.85rem', fontSize: '0.8rem' }}
onClick={() => handleOpenStudentsModal(cl)}
style={{ padding: '0.5rem 0.95rem', fontSize: '0.8rem', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}
onClick={() => openClass('classes', cl.rkId, cl.name)}
>
Chi tiết 👥
👥 Xem lớp
</button>
</td>
</tr>
@@ -311,9 +313,11 @@ export const ClassesTab: React.FC = () => {
</table>
)}
</div>
</div>
{/* Pagination control bar */}
{!loading && classes.length > 0 && (
<div className="tab-page-footer">
<div className="pagination-row">
<div>
Hiển thị lớp thứ <b>{((page - 1) * pageSize) + 1}</b> đến <b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> lớp học
@@ -326,7 +330,7 @@ export const ClassesTab: React.FC = () => {
>
</button>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'white' }}>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
Trang {page} / {Math.ceil(total / pageSize) || 1}
</span>
<button
@@ -338,91 +342,6 @@ export const ClassesTab: React.FC = () => {
</button>
</div>
</div>
)}
{/* Students roster modal */}
{activeClass && (
<div className="modal-overlay" onClick={() => setActiveClass(null)}>
<div className="modal-container" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title">
<h3>Danh sách sinh viên lớp: {activeClass.name}</h3>
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.2rem' }}>
lớp: {activeClass.classCode} | số: {activeClass.studentCount}
</p>
</div>
<button className="modal-close-btn" onClick={() => setActiveClass(null)}>
&times;
</button>
</div>
<div className="modal-body">
<div className="detail-grid">
<div>
<div className="detail-item-label">Chuyên Ngành</div>
<div className="detail-item-value">{activeClass.specializeName || 'Chưa phân phối'}</div>
</div>
<div>
<div className="detail-item-label">Chi Nhánh / Phân Hệ</div>
<div className="detail-item-value">{activeClass.systemName || 'Chung'}</div>
</div>
<div>
<div className="detail-item-label">Loại Lớp</div>
<div className="detail-item-value" style={{ textTransform: 'capitalize' }}>{activeClass.type || 'Chính thức'}</div>
</div>
</div>
{modalLoading ? (
<div className="empty-state" style={{ minHeight: '200px' }}>
<div className="sync-spinner"></div>
<p style={{ marginTop: '0.5rem' }}>Đang tải danh sách học sinh...</p>
</div>
) : modalStudents.length === 0 ? (
<div className="empty-state" style={{ minHeight: '200px' }}>
<div className="empty-state-icon">👥</div>
<h3>Lớp trống hoặc chưa đng bộ danh sách</h3>
<p style={{ fontSize: '0.85rem', maxWidth: '380px', margin: '0 auto' }}>
Hãy kiểm tra xem lớp gán môn học nào hoạt đng không. Danh sách sinh viên đưc kéo từ kết quả học tập của lớp.
</p>
</div>
) : (
<table className="data-table" style={{ fontSize: '0.85rem' }}>
<thead>
<tr>
<th> SV</th>
<th>Họ Tên</th>
<th>Email</th>
<th>Số Điện Thoại</th>
<th>Vị Trí</th>
<th>Trạng Thái</th>
</tr>
</thead>
<tbody>
{modalStudents.map(st => (
<tr key={st.rkId}>
<td style={{ fontWeight: 600 }}>{st.studentCode}</td>
<td style={{ color: 'white', fontWeight: 500 }}>{st.fullName}</td>
<td>{st.email}</td>
<td>{st.phone || '—'}</td>
<td>{st.location || '—'}</td>
<td>
<span className={`badge ${st.status === 'Đang học' || st.status === 'active' ? 'badge-success' : 'badge-muted'}`}>
{st.status || 'Đang học'}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setActiveClass(null)}>
Đóng
</button>
</div>
</div>
</div>
)}
</div>

View File

@@ -27,14 +27,17 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
}, []);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div className="tab-page">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1>Tổng Quan Hệ Thống</h1>
<p>Giám sát đng bộ quản lớp học, sinh viên từ hệ thống chính</p>
</div>
</div>
</div>
<div className="tab-page-body tab-page-scroll">
{loading && (
<div className="empty-state">
<div className="sync-spinner" style={{ width: '40px', height: '40px', borderWidth: '3px' }}></div>
@@ -65,7 +68,7 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
<div className="stat-label">Lớp đang giảng dạy</div>
<div className="stat-value" style={{ color: 'var(--success)' }}>{stats.activeClasses}</div>
</div>
<div className="stat-icon" style={{ color: 'var(--success)' }}>🟢</div>
<div className="stat-icon success"></div>
</div>
<div className="stat-card" onClick={() => onNavigate('students')} style={{ cursor: 'pointer' }}>
@@ -77,61 +80,44 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
</div>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))',
gap: '1.5rem',
marginTop: '1rem'
}}>
<div style={{
backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border-color)',
padding: '2rem',
borderRadius: '18px',
backdropFilter: 'blur(10px)'
}}>
<h2 style={{ marginBottom: '1rem', color: 'white', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
🚀 Khởi đng nhanh
</h2>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', lineHeight: '1.6', marginBottom: '1.5rem' }}>
Chào mừng bạn đến với trang quản trị <b>Simple Care</b>.
Hệ thống hỗ trợ đng bộ hóa thông tin tự đng từ cổng đào tạo chính (QLĐT), giúp lưu trữ, cập nhật trạng thái lớp học danh sách sinh viên nội bộ không lo nh hưởng đến dữ liệu hiện .
<div className="content-grid-2">
<div className="content-card">
<h2>Khởi đng nhanh</h2>
<p>
Chào mừng bạn đến với trang quản trị <strong>Simple Care</strong>.
Hệ thống hỗ trợ đng bộ hóa thông tin tự đng từ cổng đào tạo chính (QLĐT), giúp lưu trữ, cập nhật trạng thái lớp học danh sách sinh viên nội bộ.
</p>
<div style={{ display: 'flex', gap: '1rem' }}>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={() => onNavigate('classes')}>
Quản Lớp học
Quản lớp học
</button>
<button className="btn btn-secondary" onClick={() => onNavigate('students')}>
Danh sách Sinh viên
Danh sách sinh viên
</button>
</div>
</div>
<div style={{
backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border-color)',
padding: '2rem',
borderRadius: '18px',
backdropFilter: 'blur(10px)'
}}>
<h2 style={{ marginBottom: '1rem', color: 'white' }}>📋 Quy trình hoạt đng</h2>
<div className="content-card">
<h2>Quy trình hoạt đng</h2>
<ul style={{
color: 'var(--text-secondary)',
fontSize: '0.95rem',
lineHeight: '1.8',
paddingLeft: '1.25rem',
fontSize: '0.875rem',
lineHeight: 1.75,
paddingLeft: '1.15rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem'
gap: '0.5rem',
margin: 0
}}>
<li><b>Đng bộ Lớp học:</b> Tải toàn bộ danh sách lớp học các môn học tương ng. Đng thời kéo thông tin sinh viên thuộc từng lớp thông qua bảng kết quả lớp học.</li>
<li><b>Quản Lớp:</b> Lọc danh sách lớp theo Phân hệ (System ID), Tìm kiếm tên hoặc lớp Bật/Tắt cờ <b>Đang học</b>.</li>
<li><b>Đng bộ Sinh viên:</b> Thực hiện kéo toàn bộ sinh viên trên hệ thống chính (kèm thông tin liên hệ, hệ học) lưu trữ vào Database phục vụ cho học tập thi cử.</li>
<li><strong>Đng bộ lớp học:</strong> Tải danh sách lớp môn học, kéo thông tin sinh viên từng lớp qua bảng kết quả.</li>
<li><strong>Quản lớp:</strong> Lọc theo phân hệ, tìm kiếm tên/ lớp bật/tắt cờ <strong>Đang học</strong>.</li>
<li><strong>Đng bộ sinh viên:</strong> Kéo toàn bộ sinh viên t hệ thống chính (kèm thông tin liên hệ, hệ học) vào database nội bộ.</li>
</ul>
</div>
</div>
</>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,424 @@
import React, { useEffect, useState } from 'react';
import {
apiFetchActiveSchedules,
apiFetchClassSchedule,
apiApplyScheduleTemplate,
apiDeleteClassSchedule,
apiFetchAllowedApps,
apiSaveAllowedApps,
apiFetchClassSessionLogs,
apiFetchClasses
} from '../api';
import { DEFAULT_ALLOWED_APPS } from '../constants';
import type {
ClassItem,
ClassScheduleItem,
StudentSessionLogItem
} from '../api';
import { LiveProctorModal } from './LiveProctorModal';
import { openClass } from './NavHistoryBar';
import { ScheduleEditor } from './ScheduleEditor';
export const LearningTab: React.FC = () => {
const [activeClasses, setActiveClasses] = useState<any[]>([]);
const [loading, setLoading] = useState<boolean>(false);
// Lớp đang cấu hình cấu trúc lịch/app (modal)
const [configClass, setConfigClass] = useState<any | null>(null);
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
const [allowedApps, setAllowedApps] = useState<string>('');
// Lớp đang xem giám sát sinh viên
const [monitorClass, setMonitorClass] = useState<any | null>(null);
const [studentsLogs, setStudentsLogs] = useState<StudentSessionLogItem[]>([]);
// Trạng thái modal Thêm lớp vào lịch
const [showAddClassModal, setShowAddClassModal] = useState<boolean>(false);
const [allClasses, setAllClasses] = useState<ClassItem[]>([]);
const [searchClassQuery, setSearchClassQuery] = useState<string>('');
// Trạng thái modal Live stream proctor
const [proctorStudent, setProctorStudent] = useState<{ id: number; name: string; code: string } | null>(null);
const loadActiveClasses = async () => {
setLoading(true);
try {
const res = await apiFetchActiveSchedules();
setActiveClasses(res.data || []);
} catch (err) {
console.error('Failed to load active classes:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadActiveClasses();
}, []);
const handleOpenAddClass = async () => {
setShowAddClassModal(true);
try {
// Tải tối đa 1000 lớp trong hệ thống để người dùng thoải mái tìm kiếm
const res = await apiFetchClasses({ page: 1, pageSize: 1000 });
setAllClasses(res.data || []);
} catch (err) {
console.error('Error loading classes:', err);
}
};
const handleActivateClass = async (cls: ClassItem) => {
setShowAddClassModal(false);
try {
await apiApplyScheduleTemplate(cls.rkId);
await apiSaveAllowedApps(cls.rkId, DEFAULT_ALLOWED_APPS);
await loadActiveClasses();
handleOpenConfig(cls);
} catch (err) {
alert('Không thể kích hoạt lớp: ' + err);
}
};
const handleOpenConfig = async (cls: any) => {
setConfigClass(cls);
try {
const schedRes = await apiFetchClassSchedule(cls.rkId);
setSchedules(schedRes.data || []);
const appRes = await apiFetchAllowedApps(cls.rkId);
setAllowedApps(appRes.keywords || '');
} catch (err) {
console.error('Error fetching config:', err);
}
};
const handleOpenMonitor = async (cls: any) => {
setMonitorClass(cls);
setLoading(true);
try {
const logRes = await apiFetchClassSessionLogs(cls.rkId);
setStudentsLogs(logRes.data || []);
} catch (err) {
console.error('Error fetching logs:', err);
} finally {
setLoading(false);
}
};
const reloadConfigSchedules = async () => {
if (!configClass) return;
try {
const schedRes = await apiFetchClassSchedule(configClass.rkId);
setSchedules(schedRes.data || []);
loadActiveClasses();
} catch (err) {
console.error('Error reloading schedules:', err);
}
};
const handleSaveApps = async () => {
try {
await apiSaveAllowedApps(configClass.rkId, allowedApps);
alert('Lưu cấu hình ứng dụng được phép thành công!');
loadActiveClasses();
} catch (err) {
alert('Không thể lưu cấu hình app: ' + err);
}
};
const handleDeleteClass = async (cls: any) => {
if (window.confirm(`Bạn có chắc chắn muốn hủy cấu hình và dừng giám sát lớp ${cls.name}?`)) {
try {
await apiDeleteClassSchedule(cls.rkId);
loadActiveClasses();
if (monitorClass?.rkId === cls.rkId) setMonitorClass(null);
if (configClass?.rkId === cls.rkId) setConfigClass(null);
} catch (err) {
alert('Không thể hủy kích hoạt: ' + err);
}
}
};
const formatSeconds = (totalSeconds: number) => {
if (!totalSeconds) return '0s';
const hrs = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
const parts = [];
if (hrs > 0) parts.push(`${hrs}h`);
if (mins > 0) parts.push(`${mins}m`);
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
return parts.join(' ');
};
const getDayString = (day: number) => {
const days = ['Thứ 2', 'Thứ 3', 'Thứ 4', 'Thứ 5', 'Thứ 6', 'Thứ 7', 'Chủ Nhật'];
return days[day] || `Thứ ${day + 2}`;
};
const filteredAddClasses = allClasses.filter(c =>
(c.name.toLowerCase().includes(searchClassQuery.toLowerCase()) ||
c.classCode.toLowerCase().includes(searchClassQuery.toLowerCase())) &&
!activeClasses.some(ac => ac.rkId === c.rkId)
);
return (
<div className="tab-page">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1>Quản lịch học & Giám sát</h1>
<p>Chọn các lớp học từ hệ thống chính, tạo lịch học theo tuần giám sát sinh viên</p>
</div>
<button className="btn btn-primary" onClick={handleOpenAddClass}>
Chọn lớp & Tạo lịch học
</button>
</div>
</div>
<div className="tab-page-body tab-page-scroll">
<div className="content-card">
<h2 style={{ margin: '0 0 1rem 0', fontSize: '1rem', fontWeight: 700 }}>Lớp đang hoạt đng tuần này</h2>
{loading && <div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>🔄 Đang tải...</div>}
{!loading && activeClasses.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem 0', color: 'var(--text-muted)' }}>
<span style={{ fontSize: '2.5rem' }}>📅</span>
<p style={{ marginTop: '10px' }}>Chưa lớp nào đưc tạo lịch học.</p>
<button className="btn btn-secondary" onClick={handleOpenAddClass} style={{ marginTop: '10px' }}>
Bắt đu thêm lớp đu tiên
</button>
</div>
) : (
!loading && (
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th> Lớp / Tên Lớp</th>
<th>Lịch Học Trong Tuần</th>
<th>App Đưc Phép</th>
<th style={{ textAlign: 'right' }}>Thao Tác</th>
</tr>
</thead>
<tbody>
{activeClasses.map((cls) => (
<tr key={cls.rkId}>
<td style={{ maxWidth: '300px' }}>
<div style={{ fontWeight: 700, fontSize: '0.95rem', color: 'var(--primary-color)' }}>{cls.classCode}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{cls.name}
</div>
</td>
<td>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{cls.schedules && cls.schedules.length > 0 ? (
cls.schedules.map((s: any, idx: number) => (
<span key={idx} className="course-tag">
{getDayString(s.dayOfWeek)} Ca{s.period || 1}: {s.startTime}-{s.endTime}
{s.courseName ? ` · ${s.courseName}` : ''}
</span>
))
) : (
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Chưa lịch</span>
)}
</div>
</td>
<td>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', background: 'rgba(255,255,255,0.03)', padding: '4px 8px', borderRadius: '6px', fontFamily: 'monospace', display: 'inline-block', maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={cls.allowedApps}>
{cls.allowedApps || 'Mặc định'}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button
className="btn btn-primary btn-sm"
style={{ padding: '0.45rem 0.85rem', fontSize: '0.8rem', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}
onClick={() => openClass('learning', cls.rkId, cls.name)}
>
💻 Vào Workspace
</button>
<button className="btn btn-sm" style={{ background: 'rgba(198,40,40,0.1)', color: '#ff8a80', padding: '0.45rem 0.85rem', fontSize: '0.8rem' }} onClick={() => handleDeleteClass(cls)}>
🗑 Hủy
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</div>
</div>
{/* MODAL CẤU HÌNH LỊCH HỌC & APP */}
{configClass && (
<div className="modal-overlay">
<div className="modal-container" style={{ maxWidth: '800px', width: '90%' }}>
<div className="modal-header">
<div>
<h2 className="modal-title"> Cấu Hình Lịch Học & ng Dụng</h2>
<p style={{ margin: '4px 0 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>{configClass.name} ({configClass.classCode})</p>
</div>
<button className="btn btn-secondary close-btn" onClick={() => setConfigClass(null)}> Đóng</button>
</div>
<div style={{ padding: '1rem 0', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
{/* Cột trái: Quản lý Ca học */}
<div style={{ borderRight: '1px solid var(--border-color)', paddingRight: '1.5rem' }}>
<h4 style={{ margin: '0 0 1rem 0' }}>📅 Ca Học Trong Tuần</h4>
<ScheduleEditor
classId={configClass.rkId}
schedules={schedules}
onSaved={reloadConfigSchedules}
/>
</div>
{/* Cột phải: Quản lý App Whitelist */}
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div>
<h4 style={{ margin: '0 0 1rem 0' }}>🚫 App Đưc Phép Chạy</h4>
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', lineHeight: '1.4', marginBottom: '8px' }}>
Từ khóa ngăn chặn các ng dụng ngoài luồng. Các ng dụng giao diện chứa từ khóa trong danh sách này mới đưc chạy trên máy sinh viên.
</p>
<textarea
rows={6}
value={allowedApps}
onChange={e => setAllowedApps(e.target.value)}
placeholder="chrome,idea64,vscode,wails,simple_care"
style={{ width: '100%', padding: '10px', background: '#25262b', color: '#fff', border: '1px solid var(--border-color)', borderRadius: '6px', fontFamily: 'monospace', fontSize: '0.85rem', boxSizing: 'border-box' }}
/>
<small style={{ color: 'var(--text-muted)', display: 'block', marginTop: '4px' }}>Ngăn cách bằng dấu phẩy. Mặc đnh: <code>chrome,idea64,vscode,wails,simple_care</code></small>
</div>
<button className="btn btn-primary" onClick={handleSaveApps} style={{ width: '100%', marginTop: '15px' }}>
💾 Lưu Whitelist App
</button>
</div>
</div>
</div>
</div>
)}
{/* MODAL GIÁM SÁT CHI TIẾT SINH VIÊN */}
{monitorClass && (
<div className="modal-overlay">
<div className="modal-container" style={{ maxWidth: '950px', width: '95%' }}>
<div className="modal-header">
<div>
<h2 className="modal-title">📊 Nhật & Giám Sát Sinh Viên</h2>
<p style={{ margin: '4px 0 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>Lớp: <strong>{monitorClass.name}</strong> ({monitorClass.classCode})</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-secondary" onClick={() => handleOpenMonitor(monitorClass)}>🔄 Tải lại</button>
<button className="btn btn-secondary close-btn" onClick={() => setMonitorClass(null)}> Đóng</button>
</div>
</div>
<div style={{ padding: '1rem 0', maxHeight: '450px', overflowY: 'auto' }}>
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th>Học Viên</th>
<th> SV</th>
<th>Thời gian Online</th>
<th>Thời gian Offline</th>
<th>Lịch sử Wifi</th>
<th>Cập nhật cuối</th>
<th style={{ textAlign: 'right' }}>Thao tác</th>
</tr>
</thead>
<tbody>
{studentsLogs.length === 0 ? (
<tr>
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>Chưa dữ liệu sinh viên điểm danh hôm nay.</td>
</tr>
) : (
studentsLogs.map((log) => (
<tr key={log.studentRkId}>
<td>
<div style={{ fontWeight: 700 }}>{log.fullName}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{log.email}</div>
</td>
<td><code>{log.studentCode}</code></td>
<td style={{ color: '#4caf50', fontWeight: 700 }}>{formatSeconds(log.onlineSeconds)}</td>
<td style={{ color: '#f44336', fontWeight: 700 }}>{formatSeconds(log.offlineSeconds)}</td>
<td>
<span style={{ fontSize: '0.8rem', background: 'rgba(255,255,255,0.05)', padding: '3px 8px', borderRadius: '4px', border: '1px solid var(--border-color)' }}>
{log.wifiSsids || 'Chưa nhận'}
</span>
</td>
<td>{log.lastActiveAt ? new Date(log.lastActiveAt).toLocaleTimeString() : 'Chưa hoạt động'}</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-primary btn-sm"
onClick={() => setProctorStudent({ id: log.studentRkId, name: log.fullName, code: log.studentCode })}
>
🖥 Xem Camera & Screen
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
{/* MODAL THÊM LỚP VÀO LỊCH GIÁM SÁT */}
{showAddClassModal && (
<div className="modal-overlay">
<div className="modal-container" style={{ maxWidth: '500px' }}>
<div className="modal-header">
<h2 className="modal-title">🏫 Chọn Lớp Học Từ Hệ Thống</h2>
<button className="btn btn-secondary close-btn" onClick={() => setShowAddClassModal(false)}></button>
</div>
<div style={{ padding: '1rem 0' }}>
<input
type="text"
placeholder="Nhập tên lớp hoặc mã lớp cần tìm..."
value={searchClassQuery}
onChange={e => setSearchClassQuery(e.target.value)}
style={{ width: '100%', padding: '10px', background: '#25262b', color: '#fff', border: '1px solid var(--border-color)', borderRadius: '6px', marginBottom: '1rem', boxSizing: 'border-box' }}
/>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
{filteredAddClasses.length === 0 ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>Không tìm thấy lớp học phù hợp nào.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{filteredAddClasses.map(c => (
<li key={c.rkId} style={{ padding: '10px', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ maxWidth: '350px' }}>
<div style={{ fontWeight: 700, color: 'var(--primary-color)' }}>{c.classCode}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{c.name}</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)}>
Chọn
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
)}
{/* MODAL XEM STREAM CAMERA / SCREEN TRỰC TIẾP */}
{proctorStudent && (
<LiveProctorModal
studentId={proctorStudent.id}
studentName={proctorStudent.name}
studentCode={proctorStudent.code}
onClose={() => setProctorStudent(null)}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { ProctorStreamPanels } from './ProctorStreamPanels';
interface LiveProctorModalProps {
studentId: number;
studentName: string;
studentCode: string;
onClose: () => void;
}
export const LiveProctorModal: React.FC<LiveProctorModalProps> = ({
studentId,
studentName,
studentCode,
onClose,
}) => {
return (
<div className="modal-overlay" style={{ zIndex: 1100 }}>
<div className="modal-container proctor-container">
<div className="modal-header">
<div>
<h2 className="modal-title">Giám sát trực tiếp</h2>
<p style={{ margin: '4px 0 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
Sinh viên: <strong style={{ color: 'var(--text-primary)' }}>{studentName}</strong> ({studentCode})
</p>
</div>
<button className="btn btn-secondary" onClick={onClose}>Đóng</button>
</div>
<div className="proctor-content">
<ProctorStreamPanels studentId={studentId} />
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,111 @@
import React, { useEffect, useState } from 'react';
import {
TAB_LABELS,
type NavEntry,
type TabId,
parseRoute,
navigate,
readHistory,
} from '../navigation';
interface NavHistoryBarProps {
classLabel?: string;
onBack: () => void;
}
export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack }) => {
const [history, setHistory] = useState<NavEntry[]>([]);
const route = parseRoute();
useEffect(() => {
const refresh = () => setHistory(readHistory());
refresh();
window.addEventListener('popstate', refresh);
return () => window.removeEventListener('popstate', refresh);
}, []);
const currentTabLabel = TAB_LABELS[route.tab];
const recent = history.slice(0, 6);
const handlePillClick = (entry: NavEntry) => {
if (entry.kind === 'class' && entry.classId) {
navigate(entry.tab, entry.classId, entry.label);
} else {
navigate(entry.tab);
}
};
const isCurrent = (entry: NavEntry) => {
if (entry.kind === 'class' && route.classId) {
return entry.classId === route.classId;
}
return entry.kind === 'tab' && !route.classId && entry.tab === route.tab;
};
return (
<div className="nav-history-bar">
<div className="breadcrumb-row">
<button type="button" className="breadcrumb-back" onClick={onBack} title="Quay lại">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="16" height="16">
<polyline points="15 18 9 12 15 6" />
</svg>
Quay lại
</button>
<span className="breadcrumb-sep">/</span>
<button type="button" className="breadcrumb-link" onClick={() => navigate('dashboard')}>
Simple Care
</button>
<span className="breadcrumb-sep">/</span>
<button type="button" className="breadcrumb-link" onClick={() => navigate(route.tab)}>
{currentTabLabel}
</button>
{route.classId && classLabel && (
<>
<span className="breadcrumb-sep">/</span>
<span className="breadcrumb-current">{classLabel}</span>
</>
)}
</div>
{recent.length > 0 && (
<div className="history-pills-row">
<span className="history-pills-label">Gần đây</span>
<div className="history-pills">
{recent.map((entry, idx) => (
<button
key={entry.id}
type="button"
className={`history-pill ${isCurrent(entry) ? 'active' : ''}`}
onClick={() => handlePillClick(entry)}
title={entry.label}
>
<span className="history-pill-index">P{idx + 1}</span>
<span className="history-pill-label">{entry.label}</span>
</button>
))}
</div>
</div>
)}
</div>
);
};
export function useRoute() {
const [route, setRoute] = useState(parseRoute);
useEffect(() => {
const onPop = () => setRoute(parseRoute());
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
return route;
}
export function openClass(tab: TabId, classId: number, className: string) {
navigate(tab, classId, className);
}
export function openTab(tab: TabId) {
navigate(tab);
}

View File

@@ -0,0 +1,223 @@
import React, { useEffect, useState } from 'react';
import {
apiFetchAcceptedWifis,
apiSaveAcceptedWifis,
type WifiAcceptItem,
} from '../api';
import { WifiPoolModal } from './WifiPoolModal';
function bssidKey(bssid: string): string {
return bssid.toLowerCase().replace(/[^0-9a-f]/g, '');
}
function formatBssid(raw: string): string | null {
const key = bssidKey(raw);
if (key.length !== 12) return null;
return key.match(/.{2}/g)!.join(':');
}
export const NetworkTab: React.FC = () => {
const [acceptedItems, setAcceptedItems] = useState<WifiAcceptItem[]>([]);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [poolOpen, setPoolOpen] = useState(false);
const [manualSsid, setManualSsid] = useState('');
const [manualBssid, setManualBssid] = useState('');
const [manualError, setManualError] = useState<string | null>(null);
const loadAccepted = async () => {
try {
setLoading(true);
const res = await apiFetchAcceptedWifis();
setAcceptedItems(
(res.data || [])
.filter(r => r.ssid && r.bssid)
.map(r => ({ ssid: r.ssid, bssid: r.bssid }))
);
} catch {
setAcceptedItems([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAccepted();
}, []);
const addWifi = (ssid: string, bssid: string) => {
const trimmedSsid = ssid.trim();
const trimmedBssid = bssid.trim();
const key = bssidKey(trimmedBssid);
if (!trimmedSsid || key.length !== 12) return;
setAcceptedItems(prev => {
if (prev.some(item => bssidKey(item.bssid) === key)) return prev;
return [...prev, { ssid: trimmedSsid, bssid: trimmedBssid }];
});
};
const removeWifi = (bssid: string) => {
const key = bssidKey(bssid);
setAcceptedItems(prev => prev.filter(item => bssidKey(item.bssid) !== key));
};
const handleManualAdd = () => {
const ssid = manualSsid.trim();
const bssid = formatBssid(manualBssid);
if (!ssid) {
setManualError('Nhập tên WiFi (SSID).');
return;
}
if (!bssid) {
setManualError('BSSID không hợp lệ — nhập 12 ký tự hex (vd: f0:61:c0:b0:fd:d2).');
return;
}
const key = bssidKey(bssid);
if (acceptedItems.some(item => bssidKey(item.bssid) === key)) {
setManualError('BSSID này đã có trong danh sách.');
return;
}
setAcceptedItems(prev => [...prev, { ssid, bssid }]);
setManualSsid('');
setManualBssid('');
setManualError(null);
};
const handleSave = async () => {
try {
setSaving(true);
await apiSaveAcceptedWifis(acceptedItems);
alert('Đã lưu danh sách WiFi được phép!');
await loadAccepted();
} catch (e: any) {
alert(e?.message || 'Không thể lưu cấu hình WiFi');
} finally {
setSaving(false);
}
};
const acceptedBssidKeys = acceptedItems.map(item => bssidKey(item.bssid)).join(',');
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">Quản mạng</h1>
<p className="page-subtitle">
Cấu hình điểm phát WiFi đưc phép (SSID + BSSID/MAC) áp dụng <strong>toàn hệ thống</strong>.
Sinh viên không thể giả mạo bằng hotspot trùng tên.
</p>
</div>
</div>
<div className="network-grid">
<div className="card config-card-flat">
<div className="card-header-title">WiFi đưc chấp nhận</div>
<p className="config-card-desc">
Chọn từ kho WiFi hoặc thêm thủ công SSID + BSSID. Đ trống = chưa bật kiểm tra WiFi.
</p>
<form
className="network-manual-form"
onSubmit={e => {
e.preventDefault();
handleManualAdd();
}}
>
<p className="network-manual-hint">Thêm thủ công khi cần (vd: lấy BSSID từ router hoặc lệnh netsh)</p>
<input
type="text"
className="app-pool-search"
placeholder="SSID (tên WiFi)"
value={manualSsid}
onChange={e => {
setManualSsid(e.target.value);
setManualError(null);
}}
disabled={loading}
/>
<input
type="text"
className="app-pool-search"
placeholder="BSSID / MAC (f0:61:c0:b0:fd:d2)"
value={manualBssid}
onChange={e => {
setManualBssid(e.target.value);
setManualError(null);
}}
disabled={loading}
spellCheck={false}
/>
<button type="submit" className="btn btn-secondary" disabled={loading}>
+ Thêm thủ công
</button>
{manualError && <p className="network-manual-error">{manualError}</p>}
</form>
{loading ? (
<div className="app-pool-status">Đang tải...</div>
) : acceptedItems.length === 0 ? (
<div className="app-pool-status" style={{ marginBottom: '0.75rem' }}>
Chưa điểm phát nào. Mở kho WiFi đ thêm từ máy sinh viên đang kết nối đúng mạng trường.
</div>
) : (
<table className="data-table app-pool-table" style={{ marginBottom: '0.75rem' }}>
<thead>
<tr>
<th>SSID</th>
<th>BSSID (MAC)</th>
<th></th>
</tr>
</thead>
<tbody>
{acceptedItems.map(item => (
<tr key={bssidKey(item.bssid)}>
<td><code className="app-pool-kw">{item.ssid}</code></td>
<td className="app-pool-muted" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
{item.bssid}
</td>
<td>
<button
type="button"
className="btn btn-secondary app-pool-add-btn"
onClick={() => removeWifi(item.bssid)}
>
Xóa
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="network-actions">
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
Mở kho WiFi
</button>
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving || loading}>
{saving ? 'Đang lưu...' : 'Lưu cấu hình'}
</button>
</div>
</div>
<div className="card config-card-flat network-info-card">
<div className="card-header-title">Cách hoạt đng</div>
<ul className="network-info-list">
<li>Sinh viên kết nối WiFi app gửi <strong>SSID + BSSID</strong> (MAC điểm phát) lên kho</li>
<li>Thầy chọn từ kho hoặc <strong>thêm thủ công</strong> SSID + BSSID khi cần</li>
<li>Hotspot giả trùng tên nhưng MAC khác app báo lỗi thoát</li>
<li>Chưa cấu hình không chặn (đ thu thập kho trước)</li>
</ul>
</div>
</div>
<WifiPoolModal
open={poolOpen}
onClose={() => setPoolOpen(false)}
onSelect={addWifi}
acceptedBssidKeys={acceptedBssidKeys}
/>
</div>
);
};

View File

@@ -0,0 +1,224 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
interface ProctorStreamPanelsProps {
studentId: number;
layout?: 'default' | 'focus';
}
const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3];
export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
studentId,
layout = 'focus',
}) => {
const [screenFrame, setScreenFrame] = useState<string | null>(null);
const [webcamFrame, setWebcamFrame] = useState<string | null>(null);
const [streaming, setStreaming] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showWebcam, setShowWebcam] = useState(true);
const [screenZoomIdx, setScreenZoomIdx] = useState(2); // 1x
const [webcamZoomIdx, setWebcamZoomIdx] = useState(2);
const [isFullscreen, setIsFullscreen] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const intentionalClose = useRef(false);
const hasOpened = useRef(false);
const hasFrames = useRef(false);
const screenPanelRef = useRef<HTMLDivElement>(null);
const screenZoom = ZOOM_STEPS[screenZoomIdx];
const webcamZoom = ZOOM_STEPS[webcamZoomIdx];
useEffect(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.hostname}:8080/ws?role=teacher`;
intentionalClose.current = false;
hasOpened.current = false;
hasFrames.current = false;
setStreaming(false);
setErrorMessage(null);
setScreenFrame(null);
setWebcamFrame(null);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
const markStreaming = () => {
if (!hasFrames.current) {
hasFrames.current = true;
setStreaming(true);
setErrorMessage(null);
}
};
ws.onopen = () => {
hasOpened.current = true;
setStreaming(true);
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } }));
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.event === 'teacher:screenshot-stream-frame' && msg.data.studentId === studentId) {
setScreenFrame(msg.data.imageBuffer);
markStreaming();
} else if (msg.event === 'teacher:webcam-stream-frame' && msg.data.studentId === studentId) {
setWebcamFrame(msg.data.imageBuffer);
markStreaming();
} else if (msg.event === 'teacher:stream-stopped' && msg.data.studentId === studentId) {
setScreenFrame(null);
setWebcamFrame(null);
setStreaming(false);
setErrorMessage('Sinh viên đã dừng stream');
}
} catch (err) {
console.error('Error parsing WS frame:', err);
}
};
ws.onerror = () => {};
ws.onclose = () => {
if (intentionalClose.current) return;
setStreaming(false);
if (!hasOpened.current && !hasFrames.current) {
setErrorMessage('Không thể kết nối máy chủ giám sát');
}
};
return () => {
intentionalClose.current = true;
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } }));
}
ws.close();
};
}, [studentId]);
useEffect(() => {
const onFsChange = () => {
setIsFullscreen(document.fullscreenElement === screenPanelRef.current);
};
document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
const toggleFullscreen = useCallback(async () => {
const el = screenPanelRef.current;
if (!el) return;
try {
if (document.fullscreenElement === el) {
await document.exitFullscreen();
} else {
await el.requestFullscreen();
}
} catch (e) {
console.error('Fullscreen error', e);
}
}, []);
const zoomIn = (target: 'screen' | 'webcam') => {
const setter = target === 'screen' ? setScreenZoomIdx : setWebcamZoomIdx;
setter(i => Math.min(i + 1, ZOOM_STEPS.length - 1));
};
const zoomOut = (target: 'screen' | 'webcam') => {
const setter = target === 'screen' ? setScreenZoomIdx : setWebcamZoomIdx;
setter(i => Math.max(i - 1, 0));
};
const zoomReset = (target: 'screen' | 'webcam') => {
if (target === 'screen') setScreenZoomIdx(2);
else setWebcamZoomIdx(2);
};
const renderZoomToolbar = (target: 'screen' | 'webcam', zoom: number, onFs?: () => void) => (
<div className="panel-toolbar">
<button type="button" className="proctor-tool-btn" title="Thu nhỏ" onClick={() => zoomOut(target)}></button>
<span className="proctor-zoom-label">{Math.round(zoom * 100)}%</span>
<button type="button" className="proctor-tool-btn" title="Phóng to" onClick={() => zoomIn(target)}>+</button>
<button type="button" className="proctor-tool-btn" title="Về 100%" onClick={() => zoomReset(target)}>1:1</button>
{onFs && (
<button type="button" className="proctor-tool-btn proctor-tool-btn-wide" title="Toàn màn hình" onClick={onFs}>
{isFullscreen ? 'Thu nhỏ' : 'Phóng to'}
</button>
)}
</div>
);
return (
<div className="proctor-stream-wrap">
<div className="proctor-stream-toolbar">
<span className={`status-pill ${streaming || screenFrame || webcamFrame ? 'connected' : 'connecting'}`}>
{streaming || screenFrame || webcamFrame ? '● Đang phát' : '○ Đang kết nối...'}
</span>
<div className="proctor-stream-actions">
<button
type="button"
className={`btn btn-secondary proctor-action-btn ${showWebcam ? '' : 'active'}`}
onClick={() => setShowWebcam(v => !v)}
>
{showWebcam ? 'Ẩn webcam' : 'Hiện webcam'}
</button>
</div>
</div>
{errorMessage && !screenFrame && !webcamFrame && (
<div className="alert-error proctor-stream-error">{errorMessage}</div>
)}
<div className={`proctor-grid proctor-grid-${layout} ${!showWebcam ? 'proctor-grid--no-webcam' : ''}`}>
<div
className={`proctor-panel screen-panel ${isFullscreen ? 'screen-panel--fullscreen' : ''}`}
ref={screenPanelRef}
>
<div className="panel-title-row">
<span className="panel-title-text">Màn hình sinh viên</span>
{renderZoomToolbar('screen', screenZoom, toggleFullscreen)}
</div>
<div className="panel-body screen-body" onDoubleClick={toggleFullscreen} title="Double-click để phóng to">
<div className="proctor-zoom-viewport">
{screenFrame ? (
<img
src={screenFrame}
alt="Màn hình sinh viên"
className="live-frame screen-img"
style={{ transform: `scale(${screenZoom})` }}
draggable={false}
/>
) : (
<div className="no-stream-placeholder"><p>Đang chờ màn hình...</p></div>
)}
</div>
</div>
</div>
{showWebcam && (
<div className="proctor-panel webcam-panel">
<div className="panel-title-row">
<span className="panel-title-text">Webcam</span>
{renderZoomToolbar('webcam', webcamZoom)}
</div>
<div className="panel-body webcam-body">
<div className="proctor-zoom-viewport proctor-zoom-viewport--webcam">
{webcamFrame ? (
<img
src={webcamFrame}
alt="Webcam sinh viên"
className="live-frame webcam-img"
style={{ transform: `scale(${webcamZoom})` }}
draggable={false}
/>
) : (
<div className="no-stream-placeholder"><p>Đang chờ webcam...</p></div>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,244 @@
import React, { useEffect, useState } from 'react';
import {
apiApplyScheduleTemplate,
apiFetchClassCourses,
apiSaveClassSchedule,
type ClassCourseItem,
type ClassScheduleItem,
} from '../api';
const DAYS = ['Thứ 2', 'Thứ 3', 'Thứ 4', 'Thứ 5', 'Thứ 6', 'Thứ 7', 'Chủ Nhật'];
const PERIODS = [1, 2, 3, 4];
interface ScheduleEditorProps {
classId: number;
schedules: ClassScheduleItem[];
onSaved: () => void;
}
const emptyShift = (day: number, period: number): ClassScheduleItem => ({
dayOfWeek: day,
period,
startTime: '',
endTime: '',
courseId: 0,
courseName: '',
isActive: false,
});
export const ScheduleEditor: React.FC<ScheduleEditorProps> = ({ classId, schedules, onSaved }) => {
const [local, setLocal] = useState<ClassScheduleItem[]>([]);
const [courses, setCourses] = useState<ClassCourseItem[]>([]);
const [coursesWarning, setCoursesWarning] = useState('');
const [coursesError, setCoursesError] = useState('');
const [saving, setSaving] = useState(false);
const [applying, setApplying] = useState(false);
const [expandedDay, setExpandedDay] = useState<number>(0);
useEffect(() => {
const mapped = schedules.map(s => ({ ...s, period: s.period || 1, isActive: s.isActive ?? true }));
const deduped = new Map<string, ClassScheduleItem>();
for (const s of mapped) {
deduped.set(`${s.dayOfWeek}-${s.period}`, s);
}
setLocal(Array.from(deduped.values()));
}, [schedules]);
const loadCourses = async () => {
try {
setCoursesError('');
setCoursesWarning('');
const res = await apiFetchClassCourses(classId);
setCourses(res.data || []);
if (res.warning) {
setCoursesWarning(res.warning);
} else if (res.cached) {
setCoursesWarning('Đang dùng danh sách môn đã lưu. Cập nhật QLDT_TOKEN và sync lớp để làm mới.');
}
if (!res.data?.length) {
setCoursesError('Chưa có môn học. Chạy Sync lớp học hoặc cập nhật QLDT_TOKEN trong server/.env');
}
} catch (e: any) {
setCourses([]);
setCoursesError(e.message || 'Không tải được môn học');
}
};
useEffect(() => {
loadCourses();
}, [classId]);
const shiftsForDay = (day: number) =>
local.filter(s => s.dayOfWeek === day).sort((a, b) => a.period - b.period);
const updateShift = (day: number, period: number, patch: Partial<ClassScheduleItem>) => {
setLocal(prev => {
const idx = prev.findIndex(s => s.dayOfWeek === day && s.period === period);
if (idx >= 0) {
const next = [...prev];
next[idx] = { ...next[idx], ...patch };
return next;
}
return [...prev, { ...emptyShift(day, period), ...patch }];
});
};
const addShift = (day: number) => {
const existing = shiftsForDay(day).map(s => s.period);
const nextPeriod = PERIODS.find(p => !existing.includes(p));
if (!nextPeriod) {
alert('Mỗi ngày tối đa 4 ca học.');
return;
}
setLocal(prev => [...prev, emptyShift(day, nextPeriod)]);
};
const removeShift = (day: number, period: number) => {
setLocal(prev => prev.filter(s => !(s.dayOfWeek === day && s.period === period)));
};
const handleApplyTemplate = async () => {
if (!confirm('Áp dụng template 4 ca/ngày (Thứ 26)? Các ca trùng sẽ được bỏ qua.')) return;
try {
setApplying(true);
const res = await apiApplyScheduleTemplate(classId);
alert(`Đã thêm ${res.created} ca${res.skipped ? `, bỏ qua ${res.skipped} ca trùng` : ''}.`);
onSaved();
} catch (e: any) {
alert(e.message || 'Áp dụng template thất bại');
} finally {
setApplying(false);
}
};
const handleSave = async () => {
const payload = local.filter(s => s.startTime?.trim() && s.endTime?.trim());
try {
setSaving(true);
await apiSaveClassSchedule(classId, payload);
alert('Đã lưu lịch học!');
onSaved();
} catch (e: any) {
alert(e.message || 'Lưu lịch học thất bại');
} finally {
setSaving(false);
}
};
return (
<div className="schedule-editor">
<div className="schedule-editor-toolbar">
<button type="button" className="btn btn-secondary" onClick={handleApplyTemplate} disabled={applying}>
{applying ? 'Đang áp dụng...' : 'Template 4 ca (T2T6)'}
</button>
</div>
{coursesWarning && (
<p className="schedule-hint schedule-hint-warn">{coursesWarning}</p>
)}
{coursesError && (
<p className="schedule-hint schedule-hint-error">{coursesError}</p>
)}
<div className="schedule-day-tabs">
{DAYS.map((name, day) => (
<button
key={day}
type="button"
className={`schedule-day-tab ${expandedDay === day ? 'active' : ''}`}
onClick={() => setExpandedDay(day)}
>
{name}
{shiftsForDay(day).length > 0 && (
<span className="schedule-day-count">{shiftsForDay(day).filter(s => s.isActive).length}</span>
)}
</button>
))}
</div>
<div className="schedule-day-panel">
<div className="schedule-day-header">
<strong>{DAYS[expandedDay]}</strong>
<button type="button" className="btn btn-secondary" style={{ padding: '0.35rem 0.65rem', fontSize: '0.78rem' }} onClick={() => addShift(expandedDay)}>
+ Thêm ca
</button>
</div>
{shiftsForDay(expandedDay).length === 0 ? (
<p className="schedule-hint">Chưa ca học. Bấm "Thêm ca" hoặc dùng template.</p>
) : (
shiftsForDay(expandedDay).map(shift => (
<div key={`${expandedDay}-${shift.period}`} className={`schedule-shift-card ${shift.isActive ? '' : 'inactive'}`}>
<div className="schedule-shift-head">
<label className="schedule-field schedule-field-ca">
<span>Ca học</span>
<select
className="schedule-input"
value={shift.period}
onChange={e => {
const newPeriod = Number(e.target.value);
if (newPeriod !== shift.period && shiftsForDay(expandedDay).some(s => s.period === newPeriod)) {
alert(`Ca ${newPeriod} đã tồn tại trong ngày này.`);
return;
}
setLocal(prev => prev.map(s =>
s.dayOfWeek === expandedDay && s.period === shift.period
? { ...s, period: newPeriod }
: s
));
}}
>
{PERIODS.map(p => (
<option key={p} value={p}>Ca {p}</option>
))}
</select>
</label>
<label className="schedule-active-toggle">
<input type="checkbox" checked={shift.isActive} onChange={e => updateShift(expandedDay, shift.period, { isActive: e.target.checked })} />
<span>Bật</span>
</label>
<button type="button" className="btn btn-secondary schedule-remove-btn" onClick={() => removeShift(expandedDay, shift.period)} title="Xóa ca">×</button>
</div>
<div className="schedule-time-row">
<label className="schedule-field">
<span>Bắt đu</span>
<input className="schedule-input time-input" placeholder="08:00" value={shift.startTime} onChange={e => updateShift(expandedDay, shift.period, { startTime: e.target.value })} />
</label>
<label className="schedule-field">
<span>Kết thúc</span>
<input className="schedule-input time-input" placeholder="12:00" value={shift.endTime} onChange={e => updateShift(expandedDay, shift.period, { endTime: e.target.value })} />
</label>
</div>
<label className="schedule-field">
<span>Môn học (QLĐT)</span>
<select
className="schedule-input"
value={shift.courseId || ''}
onChange={e => {
const cid = Number(e.target.value);
const course = courses.find(c => c.id === cid);
updateShift(expandedDay, shift.period, {
courseId: cid,
courseName: course?.name || '',
});
}}
>
<option value=""> Chọn môn </option>
{courses.map(c => (
<option key={c.id} value={c.id}>{c.name}{c.courseCode ? ` (${c.courseCode})` : ''}</option>
))}
</select>
</label>
</div>
))
)}
</div>
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} onClick={handleSave} disabled={saving}>
{saving ? 'Đang lưu...' : 'Lưu lịch học'}
</button>
</div>
);
};

View File

@@ -0,0 +1,50 @@
import React, { useEffect, useState } from 'react';
function normalizeAvatarUrl(raw?: string | null): string {
const trimmed = raw?.trim() || '';
if (!trimmed) return '';
if (trimmed.startsWith('//')) return `https:${trimmed}`;
return trimmed;
}
interface StudentAvatarProps {
fullName: string;
avatar?: string | null;
isOnline?: boolean;
size?: number;
}
export const StudentAvatar: React.FC<StudentAvatarProps> = ({
fullName,
avatar,
isOnline = false,
size = 48,
}) => {
const [imgFailed, setImgFailed] = useState(false);
const avatarUrl = normalizeAvatarUrl(avatar);
const initial = fullName ? fullName.trim().charAt(0).toUpperCase() : 'S';
const showImage = !!avatarUrl && !imgFailed;
useEffect(() => {
setImgFailed(false);
}, [avatarUrl]);
return (
<div
className={`student-workspace-avatar ${isOnline ? 'online' : ''} ${showImage ? 'has-image' : ''}`}
style={{ width: size, height: size, fontSize: size * 0.38 }}
>
{showImage ? (
<img
src={avatarUrl}
alt={fullName}
className="student-avatar-img"
referrerPolicy="no-referrer"
onError={() => setImgFailed(true)}
/>
) : (
initial
)}
</div>
);
};

View File

@@ -0,0 +1,111 @@
import React, { useState } from 'react';
import type { StudentItem, StudentSessionLogItem } from '../api';
import { StudentAvatar } from './StudentAvatar';
import { ProctorStreamPanels } from './ProctorStreamPanels';
interface StudentDetailModalProps {
student: StudentItem;
isOnline: boolean;
sessionLog?: StudentSessionLogItem | null;
onClose: () => void;
}
const formatDuration = (totalSeconds: number) => {
const hrs = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
};
export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
student,
isOnline,
sessionLog,
onClose,
}) => {
const [showProctor, setShowProctor] = useState(false);
return (
<div className="modal-overlay student-detail-overlay" onClick={onClose}>
<div className={`modal-container student-detail-modal ${showProctor ? 'student-detail-modal--proctor' : ''}`} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="student-detail-header">
<StudentAvatar fullName={student.fullName} avatar={student.avatar} isOnline={isOnline} size={56} />
<div>
<h2 className="modal-title" style={{ margin: 0 }}>{student.fullName}</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
<span style={{ fontFamily: 'monospace', fontWeight: 700, color: 'var(--accent)' }}>{student.studentCode}</span>
{student.email && <> · {student.email}</>}
</p>
</div>
</div>
<button type="button" className="btn btn-secondary" onClick={onClose}>Đóng</button>
</div>
<div className="student-detail-body">
<div className="student-detail-info">
<div className="student-detail-meta">
<div className="student-meta-item">
<span className="student-meta-label">Trạng thái</span>
<span className={`student-meta-value ${isOnline ? 'online' : ''}`}>
{isOnline ? '● Online' : '○ Offline'}
</span>
</div>
{student.phone && (
<div className="student-meta-item">
<span className="student-meta-label">Điện thoại</span>
<span className="student-meta-value">{student.phone}</span>
</div>
)}
{sessionLog && (
<>
<div className="student-meta-item">
<span className="student-meta-label">Online (ca)</span>
<span className="student-meta-value" style={{ color: 'var(--success)', fontFamily: 'monospace' }}>
{formatDuration(sessionLog.onlineSeconds)}
</span>
</div>
<div className="student-meta-item">
<span className="student-meta-label">Offline (ca)</span>
<span className="student-meta-value" style={{ color: 'var(--danger)', fontFamily: 'monospace' }}>
{formatDuration(sessionLog.offlineSeconds)}
</span>
</div>
{sessionLog.wifiSsids && sessionLog.wifiSsids !== '—' && (
<div className="student-meta-item">
<span className="student-meta-label">WiFi</span>
<span className="student-meta-value" style={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{sessionLog.wifiSsids}
</span>
</div>
)}
</>
)}
</div>
<button
type="button"
className="btn btn-primary"
style={{ width: '100%', justifyContent: 'center' }}
onClick={() => setShowProctor(v => !v)}
>
{showProctor ? 'Ẩn giám sát' : 'Xem webcam & màn hình'}
</button>
{!isOnline && !showProctor && (
<p className="schedule-hint" style={{ margin: 0 }}>
Sinh viên offline stream chỉ khi app Simple Care đang chạy.
</p>
)}
</div>
{showProctor && (
<div className="student-detail-proctor">
<ProctorStreamPanels studentId={student.rkId} />
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -88,7 +88,8 @@ export const StudentsTab: React.FC = () => {
: 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div className="tab-page">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1>Danh Sách Sinh Viên</h1>
@@ -153,9 +154,11 @@ export const StudentsTab: React.FC = () => {
<span className="search-icon">🔍</span>
</div>
</div>
</div>
{/* Students Table */}
<div className="table-wrapper">
<div className="tab-page-body">
<div className="table-wrapper table-fill">
{loading ? (
<div className="empty-state">
<div className="sync-spinner" style={{ width: '32px', height: '32px' }}></div>
@@ -184,7 +187,7 @@ export const StudentsTab: React.FC = () => {
{students.map(st => (
<tr key={st.id}>
<td style={{ fontWeight: 600 }}>{st.studentCode}</td>
<td style={{ color: 'white', fontWeight: 500 }}>
<td style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
{st.fullName}
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {st.rkId}</div>
</td>
@@ -215,9 +218,11 @@ export const StudentsTab: React.FC = () => {
</table>
)}
</div>
</div>
{/* Pagination controls */}
{!loading && students.length > 0 && (
<div className="tab-page-footer">
<div className="pagination-row">
<div>
Hiển thị sinh viên thứ <b>{((page - 1) * pageSize) + 1}</b> đến <b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> sinh viên
@@ -230,7 +235,7 @@ export const StudentsTab: React.FC = () => {
>
</button>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'white' }}>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
Trang {page} / {Math.ceil(total / pageSize) || 1}
</span>
<button
@@ -242,6 +247,7 @@ export const StudentsTab: React.FC = () => {
</button>
</div>
</div>
</div>
)}
</div>
);

View File

@@ -0,0 +1,168 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { apiFetchWifiPool } from '../api';
import type { WifiPoolItem } from '../api';
function bssidKey(bssid: string): string {
return bssid.toLowerCase().replace(/[^0-9a-f]/g, '');
}
interface WifiPoolModalProps {
open: boolean;
onClose: () => void;
onSelect: (ssid: string, bssid: string) => void;
acceptedBssidKeys: string;
}
export const WifiPoolModal: React.FC<WifiPoolModalProps> = ({
open,
onClose,
onSelect,
acceptedBssidKeys,
}) => {
const [search, setSearch] = useState('');
const [debouncedQ, setDebouncedQ] = useState('');
const [items, setItems] = useState<WifiPoolItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const acceptedSet = useMemo(() => {
return new Set(
acceptedBssidKeys.split(',').map(s => s.trim()).filter(s => s.length === 12)
);
}, [acceptedBssidKeys]);
useEffect(() => {
if (!open) return;
const t = setTimeout(() => setDebouncedQ(search.trim()), 300);
return () => clearTimeout(t);
}, [search, open]);
const loadPool = useCallback(async (q: string) => {
try {
setLoading(true);
setError(null);
const res = await apiFetchWifiPool(q, 80);
setItems(res.data || []);
} catch (e: any) {
setItems([]);
setError(e?.message || 'Không tải được kho WiFi');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
loadPool(debouncedQ);
const interval = setInterval(() => loadPool(debouncedQ), 20000);
return () => clearInterval(interval);
}, [open, debouncedQ, loadPool]);
useEffect(() => {
if (!open) {
setSearch('');
setDebouncedQ('');
setItems([]);
setError(null);
}
}, [open]);
if (!open) return null;
const handleSelect = (ssid: string, bssid: string) => {
const key = bssidKey(bssid);
if (!ssid.trim() || key.length !== 12 || acceptedSet.has(key)) return;
onSelect(ssid, bssid);
};
return (
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
<div className="modal-container app-pool-modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div>
<h2 className="modal-title" style={{ margin: 0 }}>Kho WiFi</h2>
<p className="app-pool-modal-sub">
Điểm phát thật từ máy sinh viên (SSID + BSSID). Chọn đ thêm vào danh sách chấp nhận.
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={onClose}>Đóng</button>
</div>
<div className="app-pool-toolbar">
<input
type="search"
className="app-pool-search"
placeholder="Tìm SSID hoặc BSSID..."
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
<button type="button" className="btn btn-secondary" onClick={() => loadPool(debouncedQ)} disabled={loading}>
{loading ? 'Đang tải...' : 'Làm mới'}
</button>
</div>
<div className="app-pool-body">
{error ? (
<div className="discovered-apps-empty discovered-apps-error">{error}</div>
) : loading && items.length === 0 ? (
<div className="app-pool-status">Đang tải...</div>
) : items.length === 0 ? (
<div className="app-pool-status">
{debouncedQ ? `Không tìm thấy "${debouncedQ}"` : 'Chưa có WiFi nào trong kho.'}
</div>
) : (
<table className="data-table app-pool-table">
<thead>
<tr>
<th>SSID</th>
<th>BSSID (MAC)</th>
<th>Lần ghi nhận</th>
<th>Gần nhất</th>
<th></th>
</tr>
</thead>
<tbody>
{items.map(item => {
const key = bssidKey(item.bssid || '');
const added = key.length === 12 && acceptedSet.has(key);
return (
<tr key={item.id} className={added ? 'app-pool-row--added' : ''}>
<td><code className="app-pool-kw">{item.ssid}</code></td>
<td className="app-pool-muted" style={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{item.bssid || '—'}
</td>
<td className="app-pool-hit">{item.hitCount}×</td>
<td className="app-pool-muted">
{item.lastSeenAt ? new Date(item.lastSeenAt).toLocaleString('vi-VN') : '—'}
</td>
<td>
{!item.bssid || key.length !== 12 ? (
<span className="app-pool-muted">Thiếu BSSID</span>
) : added ? (
<span className="app-pool-added-tag">Đã </span>
) : (
<button
type="button"
className="btn btn-primary app-pool-add-btn"
onClick={() => handleSelect(item.ssid, item.bssid)}
>
+ Thêm
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
<div className="app-pool-footer">
Hiển thị {items.length} kết quả{debouncedQ ? ` cho "${debouncedQ}"` : ''}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,4 @@
/** Whitelist mặc định khi kích hoạt lớp — khớp tên tiến trình hoặc tiêu đề cửa sổ. */
export const DEFAULT_ALLOWED_APPS = 'chrome,idea64,vscode,wails,simple_care,client';
export const BASE_APP_SUGGESTIONS = ['chrome', 'vscode', 'idea64', 'wails', 'simple_care', 'client', 'cursor', 'goland', 'teams', 'zoom'];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,112 @@
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'network';
export interface NavEntry {
id: string;
kind: 'tab' | 'class';
tab: TabId;
classId?: number;
label: string;
timestamp: number;
}
export const TAB_LABELS: Record<TabId, string> = {
dashboard: 'Tổng quan',
classes: 'Lớp học',
students: 'Sinh viên',
learning: 'Giám sát & Lịch học',
network: 'Quản lý mạng',
};
const HISTORY_KEY = 'sc_nav_history';
const MAX_HISTORY = 10;
function isTabId(value: string | null): value is TabId {
return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'network';
}
export function parseRoute(): { tab: TabId; classId: number | null } {
const params = new URLSearchParams(window.location.search);
const tabParam = params.get('tab');
const tab: TabId = isTabId(tabParam) ? tabParam : 'dashboard';
const classIdRaw = params.get('classId');
const classId = classIdRaw ? Number(classIdRaw) : null;
return {
tab,
classId: classId && !Number.isNaN(classId) ? classId : null,
};
}
export function buildUrl(tab: TabId, classId?: number | null): string {
const params = new URLSearchParams();
params.set('tab', tab);
if (classId) {
params.set('classId', String(classId));
}
return `/?${params.toString()}`;
}
export function readHistory(): NavEntry[] {
try {
const raw = sessionStorage.getItem(HISTORY_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as NavEntry[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function writeHistory(entries: NavEntry[]) {
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(entries.slice(0, MAX_HISTORY)));
}
function entryKey(kind: NavEntry['kind'], tab: TabId, classId?: number) {
return kind === 'class' ? `class:${classId}` : `tab:${tab}`;
}
export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
const history = readHistory();
const id = entryKey(entry.kind, entry.tab, entry.classId);
const next: NavEntry = {
...entry,
id,
timestamp: Date.now(),
};
const filtered = history.filter((h) => h.id !== id);
writeHistory([next, ...filtered]);
}
export function navigate(tab: TabId, classId?: number | null, className?: string) {
const url = buildUrl(tab, classId);
window.history.pushState({}, '', url);
if (classId && className) {
pushNav({ kind: 'class', tab, classId, label: className });
} else {
pushNav({ kind: 'tab', tab, label: TAB_LABELS[tab] });
}
window.dispatchEvent(new PopStateEvent('popstate'));
}
export function goBack(fallbackTab: TabId = 'classes') {
const history = readHistory();
const current = parseRoute();
const currentId = current.classId
? entryKey('class', current.tab, current.classId)
: entryKey('tab', current.tab);
const remaining = history.filter((h) => h.id !== currentId);
writeHistory(remaining);
const previous = remaining[0];
if (previous) {
const url = previous.kind === 'class' && previous.classId
? buildUrl(previous.tab, previous.classId)
: buildUrl(previous.tab);
window.history.pushState({}, '', url);
} else {
window.history.pushState({}, '', buildUrl(fallbackTab));
}
window.dispatchEvent(new PopStateEvent('popstate'));
}

View File

@@ -1 +1 @@
QLDT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBodW9jbnRiQHJpa2tlaWFjYWRlbXkuY29tIiwibmFtZSI6Ik5ndXnhu4VuIFRoYW5oIELDrG5oIFBoxrDhu5tjIiwiaWQiOjI0LCJyb2xlIjpbeyJpZCI6MSwibmFtZSI6IkFETUlOIn0seyJpZCI6MywibmFtZSI6IlRFQUNIRVIifV0sInR5cGUiOiJ1c2VyIiwiaWF0IjoxNzgyNjg5MjY5LCJleHAiOjE3ODI3NzU2Njl9.0DDBte7Mm9TJhMRs5cvqBkBK-YPi-79XB-qWcrzC-SI
QLDT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBodW9jbnRiQHJpa2tlaWFjYWRlbXkuY29tIiwibmFtZSI6Ik5ndXnhu4VuIFRoYW5oIELDrG5oIFBoxrDhu5tjIiwiaWQiOjI0LCJyb2xlIjpbeyJpZCI6MSwibmFtZSI6IkFETUlOIn0seyJpZCI6MywibmFtZSI6IlRFQUNIRVIifV0sInR5cGUiOiJ1c2VyIiwiaWF0IjoxNzgyNzc3NzEzLCJleHAiOjE3ODI4NjQxMTN9.bj4nMPYZWVIsuiuA3XHLtc8yFFwAC9MDlPx-FnIM6z4

View File

@@ -5,8 +5,10 @@ go 1.26.3
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/fasthttp/websocket v1.5.3 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/gofiber/fiber/v2 v2.52.13 // indirect
github.com/gofiber/websocket/v2 v2.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -16,6 +18,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect

View File

@@ -2,10 +2,14 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -25,6 +29,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=

View File

@@ -0,0 +1,4 @@
package constants
// DefaultAllowedApps — whitelist mặc định khi kích hoạt lớp (từ khóa khớp tên tiến trình hoặc tiêu đề cửa sổ).
const DefaultAllowedApps = "chrome,idea64,vscode,wails,simple_care,client"

View File

@@ -0,0 +1,96 @@
package db
import (
"strconv"
"strings"
"time"
"server/internal/models"
"gorm.io/gorm"
)
func scheduleDayIndex(t time.Time) int {
wd := t.Weekday()
if wd == time.Sunday {
return 6
}
return int(wd) - 1
}
func parseTimeMinutes(tStr string) int {
parts := strings.Split(tStr, ":")
if len(parts) != 2 {
return 0
}
hours, _ := strconv.Atoi(parts[0])
mins, _ := strconv.Atoi(parts[1])
return hours*60 + mins
}
func studentClassIDs(db *gorm.DB, studentRkID int64) []int64 {
var classRkIDs []int64
_ = db.Model(&models.ClassStudent{}).
Where("student_rk_id = ?", studentRkID).
Pluck("class_rk_id", &classRkIDs).Error
return classRkIDs
}
// FindActiveClassAndPeriodForStudent trả về lớp + ca đang trong khung giờ học.
func FindActiveClassAndPeriodForStudent(db *gorm.DB, studentRkID int64) (int64, int) {
classRkIDs := studentClassIDs(db, studentRkID)
if len(classRkIDs) == 0 {
return 0, 0
}
var schedules []models.ClassSchedule
if err := db.Where("class_rk_id IN ? AND is_active = ?", classRkIDs, true).Find(&schedules).Error; err != nil || len(schedules) == 0 {
return classRkIDs[0], 1
}
now := time.Now()
day := scheduleDayIndex(now)
currMin := now.Hour()*60 + now.Minute()
for _, s := range schedules {
if s.DayOfWeek != day {
continue
}
startMin := parseTimeMinutes(s.StartTime)
endMin := parseTimeMinutes(s.EndTime)
if currMin >= startMin && currMin <= endMin {
period := s.Period
if period < 1 {
period = 1
}
return s.ClassRkID, period
}
}
return classRkIDs[0], 1
}
func FindActiveClassForStudent(db *gorm.DB, studentRkID int64) int64 {
classID, _ := FindActiveClassAndPeriodForStudent(db, studentRkID)
return classID
}
// ResolveSessionPeriodForDate lấy ca theo ngày (dùng khi xem nhật ký quá khứ).
func ResolveSessionPeriodForDate(db *gorm.DB, classRkID int64, date string, period int) int {
if period > 0 {
return period
}
day, err := time.Parse("2006-01-02", date)
if err != nil {
return 1
}
var slots []models.ClassSchedule
if err := db.Where("class_rk_id = ? AND day_of_week = ? AND is_active = ?", classRkID, scheduleDayIndex(day), true).
Order("period asc, start_time asc").Find(&slots).Error; err != nil || len(slots) == 0 {
return 1
}
if slots[0].Period > 0 {
return slots[0].Period
}
return 1
}

View File

@@ -0,0 +1,85 @@
package db
import (
"server/internal/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ClassCourseDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
CourseCode string `json:"courseCode"`
}
func UpsertClassCourses(db *gorm.DB, classRkID int64, items []ClassCourseDTO) error {
if len(items) == 0 {
return nil
}
rows := make([]models.ClassCourse, 0, len(items))
for _, it := range items {
if it.ID <= 0 {
continue
}
rows = append(rows, models.ClassCourse{
ClassRkID: classRkID,
CourseRkID: it.ID,
Name: it.Name,
CourseCode: it.CourseCode,
})
}
if len(rows) == 0 {
return nil
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "class_rk_id"}, {Name: "course_rk_id"}},
DoUpdates: clause.AssignmentColumns([]string{"name", "course_code", "updated_at"}),
}).Create(&rows).Error
}
func ListClassCourses(db *gorm.DB, classRkID int64) ([]ClassCourseDTO, error) {
var rows []models.ClassCourse
if err := db.Where("class_rk_id = ?", classRkID).Order("name asc").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]ClassCourseDTO, 0, len(rows))
for _, r := range rows {
out = append(out, ClassCourseDTO{
ID: r.CourseRkID,
Name: r.Name,
CourseCode: r.CourseCode,
})
}
return out, nil
}
func MergeClassCourses(base []ClassCourseDTO, extra []ClassCourseDTO) []ClassCourseDTO {
merged := make(map[int64]ClassCourseDTO, len(base)+len(extra))
for _, c := range base {
if c.ID > 0 {
merged[c.ID] = c
}
}
for _, c := range extra {
if c.ID <= 0 {
continue
}
if existing, ok := merged[c.ID]; ok {
if c.Name != "" {
existing.Name = c.Name
}
if c.CourseCode != "" {
existing.CourseCode = c.CourseCode
}
merged[c.ID] = existing
} else {
merged[c.ID] = c
}
}
out := make([]ClassCourseDTO, 0, len(merged))
for _, c := range merged {
out = append(out, c)
}
return out
}

View File

@@ -3,6 +3,9 @@ package db
import (
"fmt"
"log"
"strings"
"time"
"server/internal/models"
"gorm.io/driver/mysql"
@@ -35,10 +38,110 @@ func ConnectDB(host, port, user, password, dbName string) (*gorm.DB, error) {
func AutoMigrate(db *gorm.DB) error {
log.Println("Running AutoMigrate...")
return db.AutoMigrate(
if err := migrateLegacyWifiTables(db); err != nil {
return err
}
if err := db.AutoMigrate(
&models.Class{},
&models.ClassCourseLink{},
&models.Student{},
&models.ClassStudent{},
)
&models.ClassSchedule{},
&models.ClassCourse{},
&models.ClassAllowedApp{},
&models.AppPoolEntry{},
&models.WifiPoolEntry{},
&models.AcceptedWifi{},
&models.StudentSession{},
&models.AttendanceResult{},
); err != nil {
return err
}
if err := purgeLegacyWifiWithoutBSSID(db); err != nil {
return err
}
return migrateLegacyDiscoveredApps(db)
}
// Bảng WiFi cũ dùng ssid_key làm PK; schema mới cần id + bssid_key — GORM không tự đổi PK.
func migrateLegacyWifiTables(db *gorm.DB) error {
if db.Migrator().HasTable("accepted_wifis") {
needsReset := !db.Migrator().HasColumn(&models.AcceptedWifi{}, "id") ||
!db.Migrator().HasColumn(&models.AcceptedWifi{}, "bssid_key")
if needsReset {
log.Println("Dropping legacy accepted_wifis (SSID-only schema) — will recreate with BSSID...")
if err := db.Migrator().DropTable("accepted_wifis"); err != nil {
return err
}
}
}
if db.Migrator().HasTable("wifi_pool") {
needsReset := !db.Migrator().HasColumn(&models.WifiPoolEntry{}, "bssid_key")
if needsReset {
log.Println("Dropping legacy wifi_pool (SSID-only schema) — will recreate with BSSID...")
if err := db.Migrator().DropTable("wifi_pool"); err != nil {
return err
}
}
}
return nil
}
func purgeLegacyWifiWithoutBSSID(db *gorm.DB) error {
_ = db.Where("bssid = '' OR bssid_key = '' OR LENGTH(bssid_key) <> 12").Delete(&models.AcceptedWifi{}).Error
_ = db.Where("bssid = '' OR bssid_key = '' OR LENGTH(bssid_key) <> 12").Delete(&models.WifiPoolEntry{}).Error
return nil
}
// Gộp dữ liệu cũ (theo lớp) vào kho global nếu bảng discovered_apps còn tồn tại.
func migrateLegacyDiscoveredApps(db *gorm.DB) error {
if !db.Migrator().HasTable("discovered_apps") {
return nil
}
type legacyRow struct {
ProcessName string
WindowTitle string
Keyword string
HitCount int
LastSeenAt time.Time
StudentRkID int64
ClassRkID int64
}
var rows []legacyRow
if err := db.Table("discovered_apps").Find(&rows).Error; err != nil {
return nil
}
for _, r := range rows {
pKey := strings.ToLower(strings.TrimSpace(r.ProcessName))
if pKey == "" {
continue
}
kw := strings.TrimSuffix(pKey, ".exe")
if r.Keyword != "" {
kw = r.Keyword
}
var existing models.AppPoolEntry
if err := db.Where("process_key = ?", pKey).First(&existing).Error; err == nil {
existing.HitCount += r.HitCount
if r.LastSeenAt.After(existing.LastSeenAt) {
existing.LastSeenAt = r.LastSeenAt
existing.WindowTitle = r.WindowTitle
existing.ProcessName = r.ProcessName
existing.LastStudentRkID = r.StudentRkID
existing.LastClassRkID = r.ClassRkID
}
_ = db.Save(&existing).Error
} else {
_ = db.Create(&models.AppPoolEntry{
ProcessName: r.ProcessName,
ProcessKey: pKey,
WindowTitle: r.WindowTitle,
Keyword: kw,
HitCount: r.HitCount,
LastSeenAt: r.LastSeenAt,
LastStudentRkID: r.StudentRkID,
LastClassRkID: r.ClassRkID,
}).Error
}
}
return db.Migrator().DropTable("discovered_apps")
}

View File

@@ -3,7 +3,9 @@ package handlers
import (
"strconv"
"strings"
"time"
internalDb "server/internal/db"
"server/internal/models"
"server/internal/qldt"
"server/internal/syncjobs"
@@ -126,31 +128,8 @@ func ListClassesHandler(db *gorm.DB) fiber.Handler {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to query classes: " + err.Error()})
}
// Kéo danh sách Courses cho các lớp
var classIDs []int64
for _, r := range rows {
classIDs = append(classIDs, r.RkID)
}
coursesByClass := map[int64][]courseRefItem{}
if len(classIDs) > 0 {
var links []models.ClassCourseLink
if err := db.Where("class_rk_id IN ?", classIDs).Order("course_order asc").Find(&links).Error; err == nil {
for _, link := range links {
coursesByClass[link.ClassRkID] = append(coursesByClass[link.ClassRkID], courseRefItem{
CourseRkID: link.CourseRkID,
CourseName: link.CourseName,
})
}
}
}
out := make([]classItem, 0, len(rows))
for _, r := range rows {
courses := coursesByClass[r.RkID]
if courses == nil {
courses = []courseRefItem{}
}
out = append(out, classItem{
RkID: r.RkID,
Name: r.Name,
@@ -163,7 +142,7 @@ func ListClassesHandler(db *gorm.DB) fiber.Handler {
SystemCode: r.SystemCode,
SystemName: r.SystemName,
IsStudying: r.IsStudying,
Courses: courses,
Courses: []courseRefItem{},
})
}
@@ -176,6 +155,48 @@ func ListClassesHandler(db *gorm.DB) fiber.Handler {
}
}
// GET /api/classes/:rkId - Lấy thông tin chi tiết một lớp học
func GetClassHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
var row models.Class
if err := db.Where("rk_id = ?", rkID).First(&row).Error; err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Class not found"})
}
coursesOut := []courseRefItem{}
if cached, err := internalDb.ListClassCourses(db, rkID); err == nil {
for _, co := range cached {
coursesOut = append(coursesOut, courseRefItem{
CourseRkID: co.ID,
CourseName: co.Name,
})
}
}
out := classItem{
RkID: row.RkID,
Name: row.Name,
ClassCode: row.ClassCode,
Type: row.Type,
StudentCount: row.StudentCount,
SpecializeRkID: row.SpecializeRkID,
SpecializeName: row.SpecializeName,
SystemRkID: row.SystemRkID,
SystemCode: row.SystemCode,
SystemName: row.SystemName,
IsStudying: row.IsStudying,
Courses: coursesOut,
}
return c.JSON(out)
}
}
// Handler cập nhật trạng thái đang học (isStudying) của lớp học
func PatchClassStudyingHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
@@ -200,6 +221,45 @@ func PatchClassStudyingHandler(db *gorm.DB) fiber.Handler {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if body.IsStudying {
// 1. Kiểm tra xem lớp đã cấu hình app được phép chưa
var appConfig models.ClassAllowedApp
errApp := db.Where("class_rk_id = ?", rkID).First(&appConfig).Error
if errApp == gorm.ErrRecordNotFound || strings.TrimSpace(appConfig.Keywords) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Không thể kích hoạt trạng thái đang học: Lớp chưa được cấu hình danh sách ứng dụng cho phép."})
}
// 2. Kiểm tra xem có trong giờ học không
var schedules []models.ClassSchedule
errSched := db.Where("class_rk_id = ?", rkID).Find(&schedules).Error
inSchedule := false
if errSched == nil && len(schedules) > 0 {
now := time.Now()
goWeekday := now.Weekday()
var scheduleDay int
if goWeekday == time.Sunday {
scheduleDay = 6
} else {
scheduleDay = int(goWeekday) - 1
}
currMin := now.Hour()*60 + now.Minute()
for _, s := range schedules {
if s.DayOfWeek == scheduleDay {
startMin := parseTimeMinutes(s.StartTime)
endMin := parseTimeMinutes(s.EndTime)
if currMin >= startMin && currMin <= endMin {
inSchedule = true
break
}
}
}
}
if !inSchedule {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Không thể kích hoạt trạng thái đang học: Hiện tại ngoài giờ học theo lịch đã cài đặt."})
}
}
cl.IsStudying = body.IsStudying
if err := db.Save(&cl).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update class: " + err.Error()})
@@ -236,7 +296,32 @@ func ListClassStudentsHandler(db *gorm.DB) fiber.Handler {
}
}
return c.JSON(fiber.Map{"data": students, "total": len(students)})
type studentRow struct {
ID uint `json:"id"`
RkID int64 `json:"rkId"`
StudentCode string `json:"studentCode"`
FullName string `json:"fullName"`
Phone *string `json:"phone"`
Email string `json:"email"`
Avatar string `json:"avatar"`
}
out := make([]studentRow, 0, len(students))
for _, st := range students {
row := studentRow{
ID: st.ID,
RkID: st.RkID,
StudentCode: st.StudentCode,
FullName: st.FullName,
Phone: st.Phone,
Email: st.Email,
}
if st.Avatar != nil {
row.Avatar = *st.Avatar
}
out = append(out, row)
}
return c.JSON(fiber.Map{"data": out, "total": len(out)})
}
}

View File

@@ -0,0 +1,462 @@
package handlers
import (
"context"
"fmt"
"strconv"
"time"
internalDb "server/internal/db"
"server/internal/models"
"server/internal/qldt"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var standardShiftTemplate = []struct {
Period int
StartTime string
EndTime string
}{
{1, "07:00", "09:00"},
{2, "09:10", "11:10"},
{3, "12:10", "14:10"},
{4, "14:20", "16:20"},
}
// GET /api/classes/:rkId/courses
func GetClassCoursesHandler(db *gorm.DB, qldtClient *qldt.Client, token string) fiber.Handler {
return func(c *fiber.Ctx) error {
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
var liveErr error
if token != "" {
res, err := qldtClient.GetClassCourses(context.Background(), token, rkID)
if err == nil && len(res.Data) > 0 {
dtos := qldtCourseItemsToDTO(res.Data)
_ = internalDb.UpsertClassCourses(db, rkID, dtos)
return c.JSON(fiber.Map{"data": dtos, "source": "qldt"})
}
liveErr = err
} else {
liveErr = fmt.Errorf("QLDT_TOKEN chưa cấu hình")
}
cached, err := internalDb.ListClassCourses(db, rkID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if len(cached) > 0 {
resp := fiber.Map{
"data": cached,
"source": "cache",
"cached": true,
}
if liveErr != nil {
resp["warning"] = liveErr.Error()
}
return c.JSON(resp)
}
if liveErr != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{
"error": liveErr.Error(),
"hint": "Cập nhật QLDT_TOKEN trong server/.env rồi chạy Sync lớp học để tải danh sách môn.",
})
}
return c.JSON(fiber.Map{"data": []internalDb.ClassCourseDTO{}})
}
}
func qldtCourseItemsToDTO(items []qldt.CourseItem) []internalDb.ClassCourseDTO {
out := make([]internalDb.ClassCourseDTO, 0, len(items))
for _, it := range items {
if it.ID <= 0 {
continue
}
out = append(out, internalDb.ClassCourseDTO{
ID: it.ID,
Name: it.Name,
CourseCode: it.CourseCode,
})
}
return out
}
// POST /api/classes/:rkId/schedule/apply-template
func ApplyScheduleTemplateHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
created := 0
skipped := 0
for day := 0; day <= 4; day++ {
for _, slot := range standardShiftTemplate {
var existing int64
db.Model(&models.ClassSchedule{}).
Where("class_rk_id = ? AND day_of_week = ? AND period = ?", rkID, day, slot.Period).
Count(&existing)
if existing > 0 {
skipped++
continue
}
row := models.ClassSchedule{
ClassRkID: rkID,
DayOfWeek: day,
Period: slot.Period,
StartTime: slot.StartTime,
EndTime: slot.EndTime,
CourseID: 0,
CourseName: "",
IsActive: false,
}
if err := db.Create(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
created++
}
}
return c.JSON(fiber.Map{"ok": true, "created": created, "skipped": skipped})
}
}
type attendanceRow struct {
StudentRkID int64 `json:"studentRkId"`
StudentCode string `json:"studentCode"`
FullName string `json:"fullName"`
Email string `json:"email"`
Status int `json:"status"`
StatusLabel string `json:"statusLabel"`
OnlineMinutes int `json:"onlineMinutes"`
StatusEditedByTeacher bool `json:"statusEditedByTeacher"`
}
func resolveScheduleForPeriod(db *gorm.DB, classRkID int64, dayOfWeek, period int) (*models.ClassSchedule, error) {
var row models.ClassSchedule
err := db.Where("class_rk_id = ? AND day_of_week = ? AND period = ?", classRkID, dayOfWeek, period).First(&row).Error
if err == nil {
return &row, nil
}
var slots []models.ClassSchedule
if err := db.Where("class_rk_id = ? AND day_of_week = ?", classRkID, dayOfWeek).
Order("start_time").Find(&slots).Error; err != nil {
return nil, err
}
if period < 1 || period > len(slots) {
return nil, gorm.ErrRecordNotFound
}
return &slots[period-1], nil
}
func recalcAttendanceForPeriod(db *gorm.DB, classRkID int64, date string, period int, dayOfWeek int) error {
sched, err := resolveScheduleForPeriod(db, classRkID, dayOfWeek, period)
if err != nil {
return err
}
shiftMins := qldt.ShiftDurationMinutes(sched.StartTime, sched.EndTime)
var mappings []models.ClassStudent
if err := db.Where("class_rk_id = ?", classRkID).Find(&mappings).Error; err != nil {
return err
}
for _, m := range mappings {
var session models.StudentSession
onlineMins := 0
periods := []int{period}
if period == 1 {
periods = append(periods, 0)
}
err := db.Where("student_rk_id = ? AND class_rk_id = ? AND session_date = ? AND period IN ?",
m.StudentRkID, classRkID, date, periods).
Order("period DESC").First(&session).Error
if err == nil {
onlineMins = session.OnlineSeconds / 60
}
status := qldt.InferStatusFromOnlineMinutes(onlineMins, shiftMins)
label := qldt.AttendanceStatusLabel(status)
row := models.AttendanceResult{
ClassRkID: classRkID,
SessionDate: date,
Period: period,
StudentRkID: m.StudentRkID,
Status: status,
StatusLabel: label,
OnlineMinutes: onlineMins,
}
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "class_rk_id"},
{Name: "session_date"},
{Name: "period"},
{Name: "student_rk_id"},
},
DoUpdates: clause.Assignments(map[string]interface{}{
"online_minutes": gorm.Expr("VALUES(online_minutes)"),
"status": gorm.Expr("IF(status_edited_by_teacher, status, VALUES(status))"),
"status_label": gorm.Expr("IF(status_edited_by_teacher, status_label, VALUES(status_label))"),
"updated_at": time.Now(),
}),
}).Create(&row).Error; err != nil {
return err
}
}
return nil
}
func scheduleDayFromDate(date string) (int, error) {
t, err := time.Parse("2006-01-02", date)
if err != nil {
return 0, err
}
wd := t.Weekday()
if wd == time.Sunday {
return 6, nil
}
return int(wd) - 1, nil
}
// GET /api/classes/:rkId/attendance?date=&period=
func GetClassAttendanceHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
date := c.Query("date", qldt.NowDateVN())
period, _ := strconv.Atoi(c.Query("period", "1"))
if period < 1 {
period = 1
}
dayOfWeek, err := scheduleDayFromDate(date)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"})
}
_ = recalcAttendanceForPeriod(db, classRkID, date, period, dayOfWeek)
var mappings []models.ClassStudent
db.Where("class_rk_id = ?", classRkID).Find(&mappings)
studentIDs := make([]int64, 0, len(mappings))
for _, m := range mappings {
studentIDs = append(studentIDs, m.StudentRkID)
}
if len(studentIDs) == 0 {
return c.JSON(fiber.Map{"data": []any{}, "period": period, "date": date})
}
var students []models.Student
db.Where("rk_id IN ?", studentIDs).Find(&students)
var results []models.AttendanceResult
db.Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, date, period).Find(&results)
resMap := map[int64]models.AttendanceResult{}
for _, r := range results {
resMap[r.StudentRkID] = r
}
out := make([]attendanceRow, 0, len(students))
for _, st := range students {
r, ok := resMap[st.RkID]
row := attendanceRow{
StudentRkID: st.RkID,
StudentCode: st.StudentCode,
FullName: st.FullName,
Email: st.Email,
}
if ok {
row.Status = r.Status
row.StatusLabel = r.StatusLabel
row.OnlineMinutes = r.OnlineMinutes
row.StatusEditedByTeacher = r.StatusEditedByTeacher
}
out = append(out, row)
}
sched, _ := resolveScheduleForPeriod(db, classRkID, dayOfWeek, period)
shiftInfo := fiber.Map{}
if sched != nil {
shiftInfo = fiber.Map{
"startTime": sched.StartTime,
"endTime": sched.EndTime,
"courseId": sched.CourseID,
"courseName": sched.CourseName,
"isActive": sched.IsActive,
}
}
return c.JSON(fiber.Map{"data": out, "period": period, "date": date, "shift": shiftInfo})
}
}
// PUT /api/classes/:rkId/attendance/status
func UpdateAttendanceStatusHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
var req struct {
Date string `json:"date"`
Period int `json:"period"`
StudentRkID int64 `json:"studentRkId"`
Status int `json:"status"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
if req.Date == "" {
req.Date = qldt.NowDateVN()
}
if req.Period < 1 {
req.Period = 1
}
label := qldt.AttendanceStatusLabel(req.Status)
var row models.AttendanceResult
err := db.Where("class_rk_id = ? AND session_date = ? AND period = ? AND student_rk_id = ?",
classRkID, req.Date, req.Period, req.StudentRkID).First(&row).Error
if err == gorm.ErrRecordNotFound {
row = models.AttendanceResult{
ClassRkID: classRkID,
SessionDate: req.Date,
Period: req.Period,
StudentRkID: req.StudentRkID,
Status: req.Status,
StatusLabel: label,
StatusEditedByTeacher: true,
}
if err := db.Create(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
} else if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} else {
row.Status = req.Status
row.StatusLabel = label
row.StatusEditedByTeacher = true
if err := db.Save(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
}
return c.JSON(fiber.Map{"ok": true, "data": row})
}
}
// POST /api/classes/:rkId/attendance/push-qldt
func PushAttendanceToQLDTHandler(db *gorm.DB, qldtClient *qldt.Client, token string) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
var req struct {
Date string `json:"date"`
Period int `json:"period"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
if req.Date == "" {
req.Date = qldt.NowDateVN()
}
if req.Period < 1 {
req.Period = 1
}
if token == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "QLDT_TOKEN chưa cấu hình"})
}
dayOfWeek, _ := scheduleDayFromDate(req.Date)
sched, err := resolveScheduleForPeriod(db, classRkID, dayOfWeek, req.Period)
if err != nil || sched == nil || sched.CourseID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Ca học chưa cấu hình môn học (courseId)"})
}
userID, err := qldt.ParseUserIDFromToken(token)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Không đọc được users_id từ QLDT_TOKEN: " + err.Error()})
}
var results []models.AttendanceResult
db.Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, req.Date, req.Period).Find(&results)
if len(results) == 0 {
_ = recalcAttendanceForPeriod(db, classRkID, req.Date, req.Period, dayOfWeek)
db.Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, req.Date, req.Period).Find(&results)
}
students := make([]qldt.AttendanceStudent, 0, len(results))
for _, r := range results {
students = append(students, qldt.AttendanceStudent{
StudentID: r.StudentRkID,
Status: r.Status,
})
}
payload := &qldt.AttendancePayload{
UsersID: userID,
CoursesID: int(sched.CourseID),
Period: req.Period,
Students: students,
ClassID: classRkID,
Date: req.Date,
Type: "OFFLINE",
}
body, code, err := qldtClient.SaveAttendance(context.Background(), token, payload)
if err != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error(), "statusCode": code, "body": string(body)})
}
now := time.Now()
db.Model(&models.AttendanceResult{}).
Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, req.Date, req.Period).
Update("pushed_to_qldt_at", now)
return c.JSON(fiber.Map{"ok": true, "message": "Đã đẩy điểm danh lên QLĐT", "studentCount": len(students)})
}
}
// GET /api/classes/:rkId/attendance/shifts?date=
func ListAttendanceShiftsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
date := c.Query("date", qldt.NowDateVN())
dayOfWeek, err := scheduleDayFromDate(date)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"})
}
var slots []models.ClassSchedule
db.Where("class_rk_id = ? AND day_of_week = ?", classRkID, dayOfWeek).
Order("period, start_time").Find(&slots)
if len(slots) == 0 {
for p := 1; p <= 4; p++ {
slots = append(slots, models.ClassSchedule{
Period: p, DayOfWeek: dayOfWeek, IsActive: false,
})
}
}
out := make([]fiber.Map, 0, len(slots))
for _, s := range slots {
period := s.Period
if period == 0 {
period = 1
}
out = append(out, fiber.Map{
"period": period,
"startTime": s.StartTime,
"endTime": s.EndTime,
"courseId": s.CourseID,
"courseName": s.CourseName,
"isActive": s.IsActive,
})
}
return c.JSON(fiber.Map{"data": out, "date": date, "dayOfWeek": dayOfWeek})
}
}

View File

@@ -0,0 +1,629 @@
package handlers
import (
"strconv"
"strings"
"time"
internalDb "server/internal/db"
"server/internal/models"
"server/internal/util"
internalWs "server/internal/websocket"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
type classWithScheduleItem struct {
RkID int64 `json:"rkId"`
Name string `json:"name"`
ClassCode string `json:"classCode"`
Type string `json:"type"`
StudentCount int `json:"studentCount"`
SpecializeName string `json:"specializeName"`
SystemName string `json:"systemName"`
Schedules []models.ClassSchedule `json:"schedules"`
AllowedApps string `json:"allowedApps"`
}
// GET /api/classes/schedules - Lấy các lớp có lịch học trong tuần
func ListActiveSchedulesHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
// Tìm các classRkId có lịch học
var activeClassRkIDs []int64
if err := db.Model(&models.ClassSchedule{}).Distinct("class_rk_id").Pluck("class_rk_id", &activeClassRkIDs).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if len(activeClassRkIDs) == 0 {
return c.JSON(fiber.Map{"data": []any{}})
}
// Lấy thông tin lớp học
var classes []models.Class
if err := db.Where("rk_id IN ?", activeClassRkIDs).Find(&classes).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Lấy toàn bộ lịch học của các lớp này
var schedules []models.ClassSchedule
if err := db.Where("class_rk_id IN ?", activeClassRkIDs).Order("day_of_week, start_time").Find(&schedules).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Lấy cấu hình allowed apps của các lớp này
var allowedApps []models.ClassAllowedApp
if err := db.Where("class_rk_id IN ?", activeClassRkIDs).Find(&allowedApps).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Map dữ liệu
schedulesMap := map[int64][]models.ClassSchedule{}
for _, s := range schedules {
schedulesMap[s.ClassRkID] = append(schedulesMap[s.ClassRkID], s)
}
allowedAppsMap := map[int64]string{}
for _, a := range allowedApps {
allowedAppsMap[a.ClassRkID] = a.Keywords
}
out := make([]classWithScheduleItem, 0, len(classes))
for _, cl := range classes {
sList := schedulesMap[cl.RkID]
if sList == nil {
sList = []models.ClassSchedule{}
}
specName := ""
if cl.SpecializeName != nil {
specName = *cl.SpecializeName
}
sysName := ""
if cl.SystemName != nil {
sysName = *cl.SystemName
}
out = append(out, classWithScheduleItem{
RkID: cl.RkID,
Name: cl.Name,
ClassCode: cl.ClassCode,
Type: cl.Type,
StudentCount: cl.StudentCount,
SpecializeName: specName,
SystemName: sysName,
Schedules: sList,
AllowedApps: allowedAppsMap[cl.RkID],
})
}
return c.JSON(fiber.Map{"data": out})
}
}
// GET /api/classes/:rkId/schedule - Lấy lịch học của lớp
func GetClassScheduleHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
var schedules []models.ClassSchedule
if err := db.Where("class_rk_id = ?", rkID).Order("day_of_week, period, start_time").Find(&schedules).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": schedules})
}
}
// POST /api/classes/:rkId/schedule - Lưu lịch học của lớp
func SaveClassScheduleHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
type reqSchedule struct {
DayOfWeek int `json:"dayOfWeek"`
Period int `json:"period"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
CourseID int64 `json:"courseId"`
CourseName string `json:"courseName"`
IsActive bool `json:"isActive"`
}
var req struct {
Schedules []reqSchedule `json:"schedules"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
}
tx := db.Begin()
// Xóa lịch cũ
if err := tx.Where("class_rk_id = ?", rkID).Delete(&models.ClassSchedule{}).Error; err != nil {
tx.Rollback()
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to clear old schedules"})
}
// Insert lịch mới
if len(req.Schedules) > 0 {
var newSchedules []models.ClassSchedule
for _, s := range req.Schedules {
if strings.TrimSpace(s.StartTime) == "" || strings.TrimSpace(s.EndTime) == "" {
continue
}
period := s.Period
if period < 1 {
period = 1
}
if period > 4 {
period = 4
}
newSchedules = append(newSchedules, models.ClassSchedule{
ClassRkID: rkID,
DayOfWeek: s.DayOfWeek,
Period: period,
StartTime: s.StartTime,
EndTime: s.EndTime,
CourseID: s.CourseID,
CourseName: s.CourseName,
IsActive: s.IsActive,
})
}
if err := tx.Create(&newSchedules).Error; err != nil {
tx.Rollback()
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to insert new schedules"})
}
}
if err := tx.Commit().Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Transaction commit failed"})
}
return c.JSON(fiber.Map{"ok": true, "message": "Schedule saved successfully"})
}
}
// DELETE /api/classes/:rkId/schedule - Xóa lịch học của lớp (rút lớp khỏi active list)
func DeleteClassScheduleHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
if err := db.Where("class_rk_id = ?", rkID).Delete(&models.ClassSchedule{}).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "message": "Schedule deleted successfully"})
}
}
func parseTimeMinutes(tStr string) int {
parts := strings.Split(tStr, ":")
if len(parts) != 2 {
return 0
}
hours, _ := strconv.Atoi(parts[0])
mins, _ := strconv.Atoi(parts[1])
return hours*60 + mins
}
// GET /api/classes/:rkId/allowed-apps - Lấy danh sách app được phép
func GetAllowedAppsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
studentIDStr := c.Query("studentId", "0")
studentID, _ := strconv.ParseInt(studentIDStr, 10, 64)
if studentID > 0 {
resolvedClassID := internalDb.FindActiveClassForStudent(db, studentID)
if resolvedClassID > 0 {
rkID = resolvedClassID
}
}
// 1. Kiểm tra xem lớp đã cấu hình app được phép chưa
var appConfig models.ClassAllowedApp
errApp := db.Where("class_rk_id = ?", rkID).First(&appConfig).Error
hasAppsConfigured := (errApp == nil && strings.TrimSpace(appConfig.Keywords) != "")
// 2. Kiểm tra xem có trong giờ học không
var schedules []models.ClassSchedule
errSched := db.Where("class_rk_id = ?", rkID).Find(&schedules).Error
inSchedule := false
if errSched == nil && len(schedules) > 0 {
now := time.Now()
goWeekday := now.Weekday()
var scheduleDay int
if goWeekday == time.Sunday {
scheduleDay = 6
} else {
scheduleDay = int(goWeekday) - 1
}
currMin := now.Hour()*60 + now.Minute()
for _, s := range schedules {
if s.DayOfWeek == scheduleDay && s.IsActive {
startMin := parseTimeMinutes(s.StartTime)
endMin := parseTimeMinutes(s.EndTime)
if currMin >= startMin && currMin <= endMin {
inSchedule = true
break
}
}
}
}
// Nếu lớp chưa cấu hình app được phép, hoặc không trong giờ học
if !hasAppsConfigured || !inSchedule {
// Tắt trạng thái đang học của lớp trong DB (nếu đang bật)
var cl models.Class
if errClass := db.Where("rk_id = ?", rkID).First(&cl).Error; errClass == nil {
if cl.IsStudying {
cl.IsStudying = false
db.Save(&cl)
}
}
reason := ""
if !hasAppsConfigured {
reason = "Lớp chưa được xét danh sách ứng dụng được phép."
} else {
reason = "Hiện tại ngoài giờ học theo lịch đã cài đặt."
}
return c.JSON(fiber.Map{
"classRkId": rkID,
"keywords": "",
"exit": true,
"reason": reason,
})
}
return c.JSON(appConfig)
}
}
// POST /api/classes/:rkId/allowed-apps - Lưu danh sách app được phép
func SaveAllowedAppsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
var req struct {
Keywords string `json:"keywords"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
}
appConfig := models.ClassAllowedApp{
ClassRkID: rkID,
Keywords: req.Keywords,
}
if err := db.Save(&appConfig).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "data": appConfig})
}
}
// POST /api/student/sync-log - Nhận log học tập (online/offline time, wifi SSID) tích lũy từ client
func SyncStudentSessionLogHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
StudentRkID int64 `json:"studentRkId"`
ClassRkID int64 `json:"classRkId"`
SessionDate string `json:"sessionDate"` // YYYY-MM-DD
AddOnlineSecs int `json:"addOnlineSeconds"`
AddOfflineSecs int `json:"addOfflineSeconds"`
WifiSSID string `json:"wifiSsid"`
Avatar string `json:"avatar"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
}
if req.StudentRkID == 0 || req.SessionDate == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing key parameters"})
}
// Dynamically resolve active class + ca theo lịch học
resolvedClassID, resolvedPeriod := internalDb.FindActiveClassAndPeriodForStudent(db, req.StudentRkID)
if resolvedClassID > 0 {
req.ClassRkID = resolvedClassID
}
period := resolvedPeriod
if period < 1 {
period = 1
}
if req.ClassRkID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Student does not belong to any class"})
}
if avatar := util.NormalizeAvatarURL(req.Avatar); avatar != "" {
_ = db.Model(&models.Student{}).Where("rk_id = ?", req.StudentRkID).Update("avatar", avatar).Error
}
var session models.StudentSession
err := db.Where("student_rk_id = ? AND class_rk_id = ? AND session_date = ? AND period = ?",
req.StudentRkID, req.ClassRkID, req.SessionDate, period).First(&session).Error
now := time.Now()
if gorm.ErrRecordNotFound == err {
// Tạo record mới
session = models.StudentSession{
StudentRkID: req.StudentRkID,
ClassRkID: req.ClassRkID,
SessionDate: req.SessionDate,
Period: period,
OnlineSeconds: req.AddOnlineSecs,
OfflineSeconds: req.AddOfflineSecs,
WifiSSIDs: req.WifiSSID,
LastActiveAt: now,
}
if err := db.Create(&session).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create student session: " + err.Error()})
}
} else if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} else {
// Cộng dồn thời gian
session.OnlineSeconds += req.AddOnlineSecs
session.OfflineSeconds += req.AddOfflineSecs
session.LastActiveAt = now
// Thêm Wifi SSID nếu chưa tồn tại trong danh sách
if req.WifiSSID != "" {
ssids := strings.Split(session.WifiSSIDs, ",")
exists := false
for _, s := range ssids {
if strings.TrimSpace(s) == req.WifiSSID {
exists = true
break
}
}
if !exists {
if session.WifiSSIDs == "" {
session.WifiSSIDs = req.WifiSSID
} else {
session.WifiSSIDs = session.WifiSSIDs + "," + req.WifiSSID
}
}
}
if err := db.Save(&session).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update student session: " + err.Error()})
}
}
return c.JSON(fiber.Map{"ok": true, "session": session})
}
}
type studentSessionLogItem struct {
ID uint `json:"id"`
StudentRkID int64 `json:"studentRkId"`
StudentCode string `json:"studentCode"`
FullName string `json:"fullName"`
Email string `json:"email"`
Period int `json:"period"`
OnlineSeconds int `json:"onlineSeconds"`
OfflineSeconds int `json:"offlineSeconds"`
WifiSSIDs string `json:"wifiSsids"`
LastActiveAt time.Time `json:"lastActiveAt"`
}
// GET /api/classes/:rkId/session-logs - Lấy danh sách logs điểm danh của lớp trong ngày
func ListClassSessionLogsHandler(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")
}
period := atoiDefault(c.Query("period"), 0)
if period <= 0 {
period = internalDb.ResolveSessionPeriodForDate(db, classRkID, date, 0)
}
// Lấy danh sách học sinh của lớp từ class_students mapping
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{}})
}
var studentRkIDs []int64
for _, m := range mappings {
studentRkIDs = append(studentRkIDs, m.StudentRkID)
}
var students []models.Student
if err := db.Where("rk_id IN ?", studentRkIDs).Find(&students).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Lấy log session theo ca trong ngày
var sessions []models.StudentSession
periods := []int{period}
if period == 1 {
periods = append(periods, 0) // dữ liệu cũ gộp vào ca 1
}
if err := db.Where("class_rk_id = ? AND session_date = ? AND student_rk_id IN ? AND period IN ?",
classRkID, date, studentRkIDs, periods).Find(&sessions).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
sessionsMap := map[int64]models.StudentSession{}
for _, s := range sessions {
existing, ok := sessionsMap[s.StudentRkID]
if !ok || s.Period > existing.Period {
sessionsMap[s.StudentRkID] = s
}
}
out := make([]studentSessionLogItem, 0, len(students))
for _, st := range students {
sess, ok := sessionsMap[st.RkID]
onlineSec := 0
offlineSec := 0
wifi := "—"
var lastAct time.Time
if ok {
onlineSec = sess.OnlineSeconds
offlineSec = sess.OfflineSeconds
wifi = sess.WifiSSIDs
lastAct = sess.LastActiveAt
}
out = append(out, studentSessionLogItem{
ID: sess.ID,
StudentRkID: st.RkID,
StudentCode: st.StudentCode,
FullName: st.FullName,
Email: st.Email,
Period: period,
OnlineSeconds: onlineSec,
OfflineSeconds: offlineSec,
WifiSSIDs: wifi,
LastActiveAt: lastAct,
})
}
return c.JSON(fiber.Map{"data": out, "period": period, "date": date})
}
}
// GET /api/classes/:rkId/online-students - Lấy danh sách ID sinh viên đang online
func GetOnlineStudentsHandler(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"})
}
onlineIDs := internalWs.Hub.GetOnlineStudentIDs(classRkID)
return c.JSON(fiber.Map{"onlineStudentIds": onlineIDs})
}
}
func processKeyword(processName string) string {
name := strings.ToLower(strings.TrimSpace(processName))
return strings.TrimSuffix(name, ".exe")
}
func processKey(processName string) string {
return strings.ToLower(strings.TrimSpace(processName))
}
// POST /api/student/report-blocked-app — client báo app vừa bị chặn → kho global
func ReportBlockedAppHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
StudentRkID int64 `json:"studentRkId"`
ProcessName string `json:"processName"`
WindowTitle string `json:"windowTitle"`
}
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.ProcessName) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId and processName are required"})
}
processName := strings.TrimSpace(req.ProcessName)
pKey := processKey(processName)
keyword := processKeyword(processName)
if pKey == "" || keyword == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid processName"})
}
lastClassID := internalDb.FindActiveClassForStudent(db, req.StudentRkID)
now := time.Now()
var row models.AppPoolEntry
err := db.Where("process_key = ?", pKey).First(&row).Error
if err == gorm.ErrRecordNotFound {
row = models.AppPoolEntry{
ProcessName: processName,
ProcessKey: pKey,
WindowTitle: req.WindowTitle,
Keyword: keyword,
HitCount: 1,
LastSeenAt: now,
LastStudentRkID: req.StudentRkID,
LastClassRkID: lastClassID,
}
if err := db.Create(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
} else if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} else {
row.HitCount++
row.LastSeenAt = now
row.WindowTitle = req.WindowTitle
row.ProcessName = processName
if req.StudentRkID > 0 {
row.LastStudentRkID = req.StudentRkID
}
if lastClassID > 0 {
row.LastClassRkID = lastClassID
}
if err := db.Save(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
}
return c.JSON(fiber.Map{"ok": true, "data": row})
}
}
// GET /api/app-pool — kho app bị chặn toàn hệ thống (?q= tìm kiếm, limit= mặc định 50)
func ListAppPoolHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
q := strings.TrimSpace(c.Query("q", ""))
limit := 50
if l, err := strconv.Atoi(c.Query("limit", "50")); err == nil && l > 0 && l <= 200 {
limit = l
}
query := db.Model(&models.AppPoolEntry{})
if q != "" {
like := "%" + q + "%"
query = query.Where(
"keyword LIKE ? OR process_name LIKE ? OR window_title LIKE ? OR process_key LIKE ?",
like, like, like, like,
)
}
var rows []models.AppPoolEntry
if err := query.Order("hit_count DESC, last_seen_at DESC").Limit(limit).Find(&rows).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": rows, "q": q, "limit": limit})
}
}

View File

@@ -0,0 +1,235 @@
package handlers
import (
"strconv"
"strings"
"time"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
func normalizeSSID(ssid string) string {
return strings.TrimSpace(ssid)
}
func ssidKey(ssid string) string {
return strings.ToLower(strings.TrimSpace(ssid))
}
func normalizeBSSID(bssid string) string {
var hex []rune
for _, c := range strings.ToLower(bssid) {
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
hex = append(hex, c)
}
}
if len(hex) != 12 {
return strings.TrimSpace(bssid)
}
s := string(hex)
return s[0:2] + ":" + s[2:4] + ":" + s[4:6] + ":" + s[6:8] + ":" + s[8:10] + ":" + s[10:12]
}
func bssidKey(bssid string) string {
var hex []rune
for _, c := range strings.ToLower(bssid) {
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
hex = append(hex, c)
}
}
return string(hex)
}
type wifiItemPayload struct {
SSID string `json:"ssid"`
BSSID string `json:"bssid"`
}
// POST /api/student/report-wifi — ghi nhận SSID + BSSID vào kho
func ReportWifiHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
StudentRkID int64 `json:"studentRkId"`
SSID string `json:"ssid"`
BSSID string `json:"bssid"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
ssid := normalizeSSID(req.SSID)
bssid := normalizeBSSID(req.BSSID)
sKey := ssidKey(ssid)
bKey := bssidKey(bssid)
if sKey == "" || bKey == "" || len(bKey) != 12 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ssid and valid bssid are required"})
}
now := time.Now()
var row models.WifiPoolEntry
err := db.Where("bssid_key = ?", bKey).First(&row).Error
if err == gorm.ErrRecordNotFound {
row = models.WifiPoolEntry{
SSID: ssid,
SSIDKey: sKey,
BSSID: bssid,
BSSIDKey: bKey,
HitCount: 1,
LastSeenAt: now,
LastStudentRkID: req.StudentRkID,
}
if err := db.Create(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
} else if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} else {
row.HitCount++
row.LastSeenAt = now
row.SSID = ssid
row.SSIDKey = sKey
row.BSSID = bssid
if req.StudentRkID > 0 {
row.LastStudentRkID = req.StudentRkID
}
if err := db.Save(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
}
return c.JSON(fiber.Map{"ok": true, "data": row})
}
}
// GET /api/student/wifi-policy — danh sách điểm phát được phép (theo BSSID)
func GetStudentWifiPolicyHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var rows []models.AcceptedWifi
if err := db.Where("bssid_key <> '' AND LENGTH(bssid_key) = 12").Order("ssid asc").Find(&rows).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
items := make([]wifiItemPayload, 0, len(rows))
for _, r := range rows {
items = append(items, wifiItemPayload{SSID: r.SSID, BSSID: r.BSSID})
}
return c.JSON(fiber.Map{
"enforce": len(items) > 0,
"accepted": items,
"acceptedSsids": nil, // legacy — client mới dùng accepted
})
}
}
// GET /api/wifi-pool?q=&limit=
func ListWifiPoolHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
q := strings.TrimSpace(c.Query("q", ""))
limit := 50
if l, err := strconv.Atoi(c.Query("limit", "50")); err == nil && l > 0 && l <= 200 {
limit = l
}
query := db.Model(&models.WifiPoolEntry{}).Where("bssid_key <> '' AND LENGTH(bssid_key) = 12")
if q != "" {
like := "%" + q + "%"
query = query.Where("ssid LIKE ? OR ssid_key LIKE ? OR bssid LIKE ? OR bssid_key LIKE ?", like, like, like, like)
}
var rows []models.WifiPoolEntry
if err := query.Order("hit_count DESC, last_seen_at DESC").Limit(limit).Find(&rows).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": rows, "q": q, "limit": limit})
}
}
// GET /api/network/accepted-wifis
func ListAcceptedWifisHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var rows []models.AcceptedWifi
if err := db.Order("ssid asc, bssid asc").Find(&rows).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": rows})
}
}
// POST /api/network/accepted-wifis — thay toàn bộ danh sách (cần SSID + BSSID)
func SaveAcceptedWifisHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Items []wifiItemPayload `json:"items"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
if err := db.Where("1 = 1").Delete(&models.AcceptedWifi{}).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
now := time.Now()
count := 0
seen := map[string]bool{}
for _, item := range req.Items {
ssid := normalizeSSID(item.SSID)
bssid := normalizeBSSID(item.BSSID)
sKey := ssidKey(ssid)
bKey := bssidKey(bssid)
if sKey == "" || bKey == "" || len(bKey) != 12 || seen[bKey] {
continue
}
seen[bKey] = true
if err := db.Create(&models.AcceptedWifi{
SSID: ssid,
SSIDKey: sKey,
BSSID: bssid,
BSSIDKey: bKey,
CreatedAt: now,
}).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
count++
}
return c.JSON(fiber.Map{"ok": true, "count": count})
}
}
// POST /api/network/accepted-wifis/add
func AddAcceptedWifiHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var req wifiItemPayload
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
}
ssid := normalizeSSID(req.SSID)
bssid := normalizeBSSID(req.BSSID)
sKey := ssidKey(ssid)
bKey := bssidKey(bssid)
if sKey == "" || bKey == "" || len(bKey) != 12 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ssid and valid bssid are required"})
}
row := models.AcceptedWifi{
SSID: ssid,
SSIDKey: sKey,
BSSID: bssid,
BSSIDKey: bKey,
CreatedAt: time.Now(),
}
if err := db.Where("bssid_key = ?", bKey).FirstOrCreate(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "data": row})
}
}
// DELETE /api/network/accepted-wifis/:id
func DeleteAcceptedWifiHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"})
}
if err := db.Delete(&models.AcceptedWifi{}, id).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
}

View File

@@ -0,0 +1,212 @@
package handlers
import (
"fmt"
"strconv"
"strings"
"time"
internalDb "server/internal/db"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
type studentShiftItem struct {
Period int `json:"period"`
CourseName string `json:"courseName"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
IsActiveNow bool `json:"isActiveNow"`
OnlineSeconds int `json:"onlineSeconds"`
OfflineSeconds int `json:"offlineSeconds"`
AttendanceStatus int `json:"attendanceStatus"`
AttendanceLabel string `json:"attendanceLabel"`
}
// GET /api/student/status — trạng thái giám sát, lớp/ca hiện tại, tích lũy & điểm danh hôm nay
func GetStudentStatusHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
studentID, err := strconv.ParseInt(c.Query("studentRkId", "0"), 10, 64)
if err != nil || studentID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId is required"})
}
now := time.Now()
today := now.Format("2006-01-02")
dayIdx := weekdayIndex(now)
currMin := now.Hour()*60 + now.Minute()
classID, activePeriod := internalDb.FindActiveClassAndPeriodForStudent(db, studentID)
if classID <= 0 {
var link models.ClassStudent
if err := db.Where("student_rk_id = ?", studentID).First(&link).Error; err == nil {
classID = link.ClassRkID
}
}
className, classCode := "", ""
if classID > 0 {
var cl models.Class
if err := db.Where("rk_id = ?", classID).First(&cl).Error; err == nil {
className = cl.Name
classCode = cl.ClassCode
}
}
var todaySchedules []models.ClassSchedule
if classID > 0 {
_ = db.Where("class_rk_id = ? AND day_of_week = ? AND is_active = ?", classID, dayIdx, true).
Order("period asc, start_time asc").Find(&todaySchedules).Error
}
inScheduleNow := false
currentPeriod := 0
currentCourse, currentStart, currentEnd := "", "", ""
for _, s := range todaySchedules {
startMin := parseTimeMinutes(s.StartTime)
endMin := parseTimeMinutes(s.EndTime)
if currMin >= startMin && currMin <= endMin {
inScheduleNow = true
currentPeriod = s.Period
if currentPeriod < 1 {
currentPeriod = 1
}
currentCourse = s.CourseName
currentStart = s.StartTime
currentEnd = s.EndTime
break
}
}
if !inScheduleNow && activePeriod > 0 {
currentPeriod = activePeriod
}
hasApps := false
keywords := ""
if classID > 0 {
var appConfig models.ClassAllowedApp
if err := db.Where("class_rk_id = ?", classID).First(&appConfig).Error; err == nil {
keywords = strings.TrimSpace(appConfig.Keywords)
hasApps = keywords != ""
}
}
monitorMode := "outside_schedule"
monitorLabel := "Ngoài giờ học"
if classID <= 0 {
monitorMode = "not_configured"
monitorLabel = "Chưa xác định được lớp học"
} else if !hasApps {
monitorMode = "not_configured"
monitorLabel = "Lớp chưa cấu hình ứng dụng được phép"
} else if inScheduleNow {
monitorMode = "learning"
if currentCourse != "" {
monitorLabel = fmt.Sprintf("Ca %d — %s (%s%s)", currentPeriod, currentCourse, currentStart, currentEnd)
} else {
monitorLabel = fmt.Sprintf("Đang giám sát — Ca %d", currentPeriod)
}
}
// monitorMode = "exam" — dành cho phòng thi (sẽ bổ sung sau)
sessionMap := map[int]models.StudentSession{}
attMap := map[int]models.AttendanceResult{}
if classID > 0 {
var sessions []models.StudentSession
_ = db.Where("student_rk_id = ? AND class_rk_id = ? AND session_date = ?", studentID, classID, today).Find(&sessions).Error
for _, s := range sessions {
p := s.Period
if p < 1 {
p = 1
}
sessionMap[p] = s
}
var attendances []models.AttendanceResult
_ = db.Where("student_rk_id = ? AND class_rk_id = ? AND session_date = ?", studentID, classID, today).Find(&attendances).Error
for _, a := range attendances {
p := a.Period
if p < 1 {
p = 1
}
attMap[p] = a
}
}
shifts := make([]studentShiftItem, 0, len(todaySchedules))
for _, sch := range todaySchedules {
period := sch.Period
if period < 1 {
period = 1
}
sess := sessionMap[period]
att := attMap[period]
startMin := parseTimeMinutes(sch.StartTime)
endMin := parseTimeMinutes(sch.EndTime)
isActiveNow := currMin >= startMin && currMin <= endMin
attStatus := -1
attLabel := "Chưa tính"
if att.ID > 0 {
attStatus = att.Status
attLabel = att.StatusLabel
if attLabel == "" {
attLabel = attendanceLabelFallback(att.Status)
}
}
shifts = append(shifts, studentShiftItem{
Period: period,
CourseName: sch.CourseName,
StartTime: sch.StartTime,
EndTime: sch.EndTime,
IsActiveNow: isActiveNow,
OnlineSeconds: sess.OnlineSeconds,
OfflineSeconds: sess.OfflineSeconds,
AttendanceStatus: attStatus,
AttendanceLabel: attLabel,
})
}
return c.JSON(fiber.Map{
"sessionDate": today,
"monitorMode": monitorMode,
"monitorLabel": monitorLabel,
"classRkId": classID,
"className": className,
"classCode": classCode,
"currentPeriod": currentPeriod,
"currentCourseName": currentCourse,
"currentShiftStart": currentStart,
"currentShiftEnd": currentEnd,
"inScheduleNow": inScheduleNow,
"blockerActive": hasApps && inScheduleNow,
"allowedKeywords": keywords,
"shifts": shifts,
})
}
}
func weekdayIndex(t time.Time) int {
wd := t.Weekday()
if wd == time.Sunday {
return 6
}
return int(wd) - 1
}
func attendanceLabelFallback(status int) string {
switch status {
case 1:
return "Nghỉ có phép"
case 2:
return "Nghỉ nửa buổi"
case 3:
return "Đi học muộn"
case 4:
return "Đi học đầy đủ"
default:
return "Nghỉ không phép"
}
}

View File

@@ -22,16 +22,6 @@ type Class struct {
func (Class) TableName() string { return "classes" }
// ClassCourseLink lưu danh sách môn học của mỗi lớp
type ClassCourseLink struct {
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"`
CourseRkID int64 `gorm:"column:course_rk_id;primaryKey;not null" json:"courseRkId"`
CourseName string `gorm:"column:course_name;size:512" json:"courseName"`
CourseOrder int `gorm:"column:course_order;default:0" json:"courseOrder"`
}
func (ClassCourseLink) TableName() string { return "class_course_links" }
// Student đại diện cho sinh viên
type Student struct {
ID uint `gorm:"primaryKey" json:"id"`
@@ -48,6 +38,7 @@ type Student struct {
Location *string `gorm:"column:location;size:32" json:"location"`
SystemID *int64 `gorm:"column:system_id" json:"systemId"`
SystemName *string `gorm:"column:system_name;size:255" json:"systemName"`
Avatar *string `gorm:"column:avatar;size:512" json:"avatar"`
}
func (Student) TableName() string { return "students" }
@@ -59,3 +50,119 @@ type ClassStudent struct {
}
func (ClassStudent) TableName() string { return "class_students" }
// ClassCourse môn học thuộc lớp (cache từ QLĐT)
type ClassCourse struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ClassRkID int64 `gorm:"column:class_rk_id;not null;uniqueIndex:uq_class_course,priority:1" json:"classRkId"`
CourseRkID int64 `gorm:"column:course_rk_id;not null;uniqueIndex:uq_class_course,priority:2" json:"courseRkId"`
Name string `gorm:"column:name;size:512" json:"name"`
CourseCode string `gorm:"column:course_code;size:128" json:"courseCode"`
}
func (ClassCourse) TableName() string { return "class_courses" }
// ClassSchedule lưu cấu hình lịch học theo tuần của lớp (nhiều ca/ngày)
type ClassSchedule struct {
ID uint `gorm:"primaryKey" json:"id"`
ClassRkID int64 `gorm:"column:class_rk_id;index;not null" json:"classRkId"`
DayOfWeek int `gorm:"column:day_of_week;not null" json:"dayOfWeek"` // 0=Monday, 6=Sunday
Period int `gorm:"column:period;not null;default:1" json:"period"` // Ca 1..4
StartTime string `gorm:"column:start_time;size:8;not null" json:"startTime"`
EndTime string `gorm:"column:end_time;size:8;not null" json:"endTime"`
CourseID int64 `gorm:"column:course_id;not null" json:"courseId"`
CourseName string `gorm:"column:course_name;size:512;not null" json:"courseName"`
IsActive bool `gorm:"column:is_active;not null;default:true" json:"isActive"`
}
func (ClassSchedule) TableName() string { return "class_schedules" }
// AttendanceResult điểm danh theo ca — khóa khi giáo viên sửa thủ công
type AttendanceResult struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ClassRkID int64 `gorm:"column:class_rk_id;not null;uniqueIndex:idx_att_lookup,priority:1" json:"classRkId"`
SessionDate string `gorm:"column:session_date;size:10;not null;uniqueIndex:idx_att_lookup,priority:2" json:"sessionDate"`
Period int `gorm:"column:period;not null;uniqueIndex:idx_att_lookup,priority:3" json:"period"`
StudentRkID int64 `gorm:"column:student_rk_id;not null;uniqueIndex:idx_att_lookup,priority:4" json:"studentRkId"`
Status int `gorm:"column:status;not null;default:0" json:"status"`
StatusLabel string `gorm:"column:status_label;size:64;not null" json:"statusLabel"`
OnlineMinutes int `gorm:"column:online_minutes;not null;default:0" json:"onlineMinutes"`
StatusEditedByTeacher bool `gorm:"column:status_edited_by_teacher;not null;default:false" json:"statusEditedByTeacher"`
PushedToQLDTAt *time.Time `gorm:"column:pushed_to_qldt_at" json:"pushedToQldtAt,omitempty"`
}
func (AttendanceResult) TableName() string { return "attendance_results" }
// ClassAllowedApp lưu từ khóa app được mở của lớp
type ClassAllowedApp struct {
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"`
Keywords string `gorm:"column:keywords;type:text" json:"keywords"` // phân tách bằng dấu phẩy
}
func (ClassAllowedApp) TableName() string { return "class_allowed_apps" }
// AppPoolEntry — kho app bị chặn toàn hệ thống (mọi lớp dùng chung để gợi ý whitelist)
type AppPoolEntry struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ProcessName string `gorm:"column:process_name;size:255;not null" json:"processName"`
ProcessKey string `gorm:"column:process_key;size:255;not null;uniqueIndex" json:"-"`
WindowTitle string `gorm:"column:window_title;type:text" json:"windowTitle"`
Keyword string `gorm:"column:keyword;size:100;not null" json:"keyword"`
HitCount int `gorm:"column:hit_count;default:1" json:"hitCount"`
LastSeenAt time.Time `gorm:"column:last_seen_at" json:"lastSeenAt"`
LastStudentRkID int64 `gorm:"column:last_student_rk_id" json:"lastStudentRkId,omitempty"`
LastClassRkID int64 `gorm:"column:last_class_rk_id" json:"lastClassRkId,omitempty"`
}
func (AppPoolEntry) TableName() string { return "app_pool" }
// WifiPoolEntry — kho WiFi phát hiện từ máy sinh viên (SSID + BSSID điểm phát)
type WifiPoolEntry struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
SSID string `gorm:"column:ssid;size:255;not null" json:"ssid"`
SSIDKey string `gorm:"column:ssid_key;size:255;not null;index" json:"-"`
BSSID string `gorm:"column:bssid;size:32;not null" json:"bssid"`
BSSIDKey string `gorm:"column:bssid_key;size:32;not null;uniqueIndex" json:"-"`
HitCount int `gorm:"column:hit_count;default:1" json:"hitCount"`
LastSeenAt time.Time `gorm:"column:last_seen_at" json:"lastSeenAt"`
LastStudentRkID int64 `gorm:"column:last_student_rk_id" json:"lastStudentRkId,omitempty"`
}
func (WifiPoolEntry) TableName() string { return "wifi_pool" }
// AcceptedWifi — điểm phát được phép (xác thực theo BSSID, không chỉ tên SSID)
type AcceptedWifi struct {
ID uint `gorm:"primaryKey" json:"id"`
SSID string `gorm:"column:ssid;size:255;not null" json:"ssid"`
SSIDKey string `gorm:"column:ssid_key;size:255;not null;index" json:"-"`
BSSID string `gorm:"column:bssid;size:32;not null" json:"bssid"`
BSSIDKey string `gorm:"column:bssid_key;size:32;not null;uniqueIndex" json:"-"`
CreatedAt time.Time `json:"createdAt"`
}
func (AcceptedWifi) TableName() string { return "accepted_wifis" }
// StudentSession lưu log học tập theo ca trong ngày
type StudentSession struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
StudentRkID int64 `gorm:"column:student_rk_id;not null;uniqueIndex:idx_student_session,priority:1" json:"studentRkId"`
ClassRkID int64 `gorm:"column:class_rk_id;not null;uniqueIndex:idx_student_session,priority:2" json:"classRkId"`
SessionDate string `gorm:"column:session_date;size:10;not null;uniqueIndex:idx_student_session,priority:3" json:"sessionDate"` // YYYY-MM-DD
Period int `gorm:"column:period;not null;default:0;uniqueIndex:idx_student_session,priority:4" json:"period"` // Ca 1..4 (0 = dữ liệu cũ)
OnlineSeconds int `gorm:"column:online_seconds;default:0" json:"onlineSeconds"`
OfflineSeconds int `gorm:"column:offline_seconds;default:0" json:"offlineSeconds"`
WifiSSIDs string `gorm:"column:wifi_ssids;type:text" json:"wifiSsids"`
LastActiveAt time.Time `gorm:"column:last_active_at" json:"lastActiveAt"`
}
func (StudentSession) TableName() string { return "student_sessions" }

View File

@@ -0,0 +1,257 @@
package qldt
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type CourseItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
CourseCode string `json:"courseCode"`
}
type CoursesResponse struct {
Data []CourseItem `json:"data"`
Message string `json:"message"`
StatusCode int `json:"statusCode"`
}
type AttendanceStudent struct {
StudentID int64 `json:"student_id"`
Status int `json:"status"`
}
type AttendancePayload struct {
UsersID int `json:"users_id"`
CoursesID int `json:"courses_id"`
Period int `json:"period"`
Students []AttendanceStudent `json:"students"`
ClassID int64 `json:"class_id"`
Date string `json:"date"`
Type string `json:"type"`
}
func ParseUserIDFromToken(token string) (int, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return 0, fmt.Errorf("invalid jwt")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return 0, err
}
var claims struct {
ID int `json:"id"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return 0, err
}
if claims.ID == 0 {
return 0, fmt.Errorf("token missing user id")
}
return claims.ID, nil
}
func (c *Client) GetClassCourses(ctx context.Context, token string, classID int64) (*CoursesResponse, error) {
u := fmt.Sprintf("%s/courses/class/%d", c.baseURL, classID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
c.setAuthHeaders(req, token)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("qldt courses http %d: %s", resp.StatusCode, string(body))
}
var out CoursesResponse
if err := json.Unmarshal(body, &out); err != nil {
return nil, err
}
if out.StatusCode == 401 || strings.Contains(strings.ToLower(out.Message), "expired") {
return nil, fmt.Errorf("qldt token invalid: %s", out.Message)
}
return &out, nil
}
func (c *Client) SaveAttendance(ctx context.Context, token string, payload *AttendancePayload) ([]byte, int, error) {
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, 0, err
}
u := c.baseURL + "/attendance"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewBuffer(jsonData))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/json")
c.setAuthHeaders(req, token)
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return body, resp.StatusCode, fmt.Errorf("qldt attendance post %d: %s", resp.StatusCode, string(body))
}
return body, resp.StatusCode, nil
}
func (c *Client) UpdateAttendanceDetailStatus(ctx context.Context, token string, detailID int64, status string) error {
payload := map[string]string{"status": status}
jsonData, _ := json.Marshal(payload)
u := fmt.Sprintf("%s/attendance-detail/status/%d", c.baseURL, detailID)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, u, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.setAuthHeaders(req, token)
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("qldt patch detail %d: %s", resp.StatusCode, string(body))
}
return nil
}
type PortalAttendanceDetail struct {
ID int64 `json:"id"`
Status string `json:"status"`
Student struct {
ID int64 `json:"id"`
} `json:"student"`
}
type PortalAttendanceSession struct {
ID int64 `json:"id"`
Date string `json:"date"`
Period int `json:"period"`
AttendanceDetail []PortalAttendanceDetail `json:"attendanceDetail"`
}
type PortalAttendanceResponse struct {
Data map[string][]PortalAttendanceSession `json:"data"`
Message string `json:"message"`
}
func (c *Client) GetAttendance(ctx context.Context, token string, classID int64, courseID int64) (*PortalAttendanceResponse, error) {
u, _ := url.Parse(c.baseURL + "/attendance")
q := u.Query()
q.Set("class_id", fmt.Sprintf("%d", classID))
q.Set("course_id", fmt.Sprintf("%d", courseID))
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
c.setAuthHeaders(req, token)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("qldt get attendance %d: %s", resp.StatusCode, string(body))
}
var out PortalAttendanceResponse
if err := json.Unmarshal(body, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) setAuthHeaders(req *http.Request, token string) {
req.Header.Set("Authorization", "Bearer "+token)
if c.origin != "" {
req.Header.Set("Origin", c.origin)
req.Header.Set("Referer", c.origin+"/")
}
req.Header.Set("Accept", "application/json")
}
func AttendanceStatusLabel(status int) string {
switch status {
case 1:
return "Nghỉ có phép"
case 2:
return "Nghỉ nửa buổi"
case 3:
return "Đi học muộn"
case 4:
return "Đi học đầy đủ"
default:
return "Nghỉ không phép"
}
}
func ShiftDurationMinutes(start, end string) int {
return parseHM(end) - parseHM(start)
}
func parseHM(t string) int {
parts := strings.Split(t, ":")
if len(parts) < 2 {
return 0
}
h, m := 0, 0
fmt.Sscanf(parts[0], "%d", &h)
fmt.Sscanf(parts[1], "%d", &m)
return h*60 + m
}
func InferStatusFromOnlineMinutes(onlineMins, shiftMins int) int {
if shiftMins <= 0 {
if onlineMins > 0 {
return 4
}
return 0
}
rate := float64(onlineMins) / float64(shiftMins)
switch {
case rate >= 0.85:
return 4
case rate >= 0.5:
return 2
case onlineMins > 0:
return 3
default:
return 0
}
}
func NowDateVN() string {
return time.Now().Format("2006-01-02")
}

View File

@@ -48,6 +48,7 @@ type StudentDTO struct {
Gender *int `json:"gender"`
Status *string `json:"status"`
Location *string `json:"location"`
Avatar *string `json:"avatar"`
System *struct {
ID int64 `json:"id"`
Name string `json:"name"`

View File

@@ -9,7 +9,9 @@ import (
"time"
"server/internal/models"
internalDb "server/internal/db"
"server/internal/qldt"
"server/internal/util"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -104,6 +106,15 @@ func (j *StudentsSyncJob) run(db *gorm.DB, client *qldt.Client, token string) {
}
}
avatarVal := ""
if s.Avatar != nil {
avatarVal = util.NormalizeAvatarURL(*s.Avatar)
}
var avatar *string
if avatarVal != "" {
avatar = &avatarVal
}
st := models.Student{
RkID: s.ID,
StudentCode: s.StudentCode,
@@ -114,6 +125,7 @@ func (j *StudentsSyncJob) run(db *gorm.DB, client *qldt.Client, token string) {
Gender: s.Gender,
Status: s.Status,
Location: s.Location,
Avatar: avatar,
}
if s.System != nil {
sysID := s.System.ID
@@ -135,6 +147,7 @@ func (j *StudentsSyncJob) run(db *gorm.DB, client *qldt.Client, token string) {
"gender",
"status",
"location",
"avatar",
"system_id",
"system_name",
"updated_at",
@@ -302,29 +315,35 @@ func (j *ClassesSyncJob) run(db *gorm.DB, client *qldt.Client, token string) {
}
}
// Lưu liên kết Course
if err := tx.Where("class_rk_id = ?", cl.ID).Delete(&models.ClassCourseLink{}).Error; err != nil {
tx.Rollback()
j.setError("Failed to clear old class course links: " + err.Error())
return
}
var courseLinks []models.ClassCourseLink
for ord, co := range cl.Courses {
if co.ID == 0 {
continue
}
courseLinks = append(courseLinks, models.ClassCourseLink{
ClassRkID: cl.ID,
CourseRkID: co.ID,
CourseName: co.Name,
CourseOrder: ord,
// Lưu danh sách môn học của lớp (từ API lớp + portal courses)
courseDTOs := make([]internalDb.ClassCourseDTO, 0, len(cl.Courses))
for _, co := range cl.Courses {
if co.ID > 0 {
courseDTOs = append(courseDTOs, internalDb.ClassCourseDTO{
ID: co.ID,
Name: co.Name,
})
}
if len(courseLinks) > 0 {
if err := tx.Create(&courseLinks).Error; err != nil {
}
if portalRes, err := client.GetClassCourses(ctx, token, cl.ID); err == nil && len(portalRes.Data) > 0 {
portalDTOs := make([]internalDb.ClassCourseDTO, 0, len(portalRes.Data))
for _, co := range portalRes.Data {
if co.ID > 0 {
portalDTOs = append(portalDTOs, internalDb.ClassCourseDTO{
ID: co.ID,
Name: co.Name,
CourseCode: co.CourseCode,
})
}
}
courseDTOs = internalDb.MergeClassCourses(courseDTOs, portalDTOs)
}
if len(courseDTOs) > 0 {
if err := internalDb.UpsertClassCourses(tx, cl.ID, courseDTOs); err != nil {
tx.Rollback()
j.setError("Failed to insert class course links: " + err.Error())
j.setError("Failed to save class courses: " + err.Error())
return
}
}
@@ -354,6 +373,7 @@ func (j *ClassesSyncJob) run(db *gorm.DB, client *qldt.Client, token string) {
"gender",
"status",
"location",
"avatar",
"system_id",
"system_name",
"updated_at",
@@ -521,6 +541,11 @@ func extractStudentsFromDashboard(payloadJSON []byte) ([]models.Student, error)
status, _ := studentObj["status"].(string)
location, _ := studentObj["location"].(string)
var avatar *string
if av := util.PickAvatarFromMap(studentObj); av != "" {
avatar = &av
}
student := models.Student{
RkID: rkID,
StudentCode: stCode,
@@ -531,6 +556,7 @@ func extractStudentsFromDashboard(payloadJSON []byte) ([]models.Student, error)
Gender: gender,
Status: &status,
Location: &location,
Avatar: avatar,
}
if sysVal, ok := studentObj["system"].(map[string]interface{}); ok && sysVal != nil {

View File

@@ -0,0 +1,28 @@
package util
import "strings"
func NormalizeAvatarURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "//") {
return "https:" + raw
}
return raw
}
func PickAvatarFromMap(m map[string]interface{}) string {
if m == nil {
return ""
}
for _, key := range []string{"avatar", "avatarUrl", "avatar_url", "profileImage"} {
if av, ok := m[key].(string); ok {
if normalized := NormalizeAvatarURL(av); normalized != "" {
return normalized
}
}
}
return ""
}

View File

@@ -0,0 +1,286 @@
package websocket
import (
"encoding/json"
"log"
"strconv"
"sync"
"github.com/gofiber/websocket/v2"
"gorm.io/gorm"
internalDb "server/internal/db"
)
type SocketMsg struct {
Event string `json:"event"`
Data map[string]any `json:"data"`
}
type SocketClient struct {
Conn *websocket.Conn
StudentID int64
ClassID int64
Role string // "student" | "teacher"
Addr string
}
type WsHub struct {
mu sync.RWMutex
students map[int64]*SocketClient
teachers map[string]*SocketClient
subscribers map[int64][]string // studentId -> list of teacher connection addresses
}
var Hub = &WsHub{
students: make(map[int64]*SocketClient),
teachers: make(map[string]*SocketClient),
subscribers: make(map[int64][]string),
}
func (h *WsHub) GetOnlineStudentIDs(classID int64) []int64 {
h.mu.RLock()
defer h.mu.RUnlock()
var ids []int64
for _, client := range h.students {
if client.ClassID == classID {
ids = append(ids, client.StudentID)
}
}
if ids == nil {
return []int64{}
}
return ids
}
func (h *WsHub) Register(c *SocketClient) {
h.mu.Lock()
defer h.mu.Unlock()
c.Addr = c.Conn.RemoteAddr().String()
if c.Role == "student" {
h.students[c.StudentID] = c
log.Printf("[WS] Student %d registered (Address: %s, Class: %d)", c.StudentID, c.Addr, c.ClassID)
} else if c.Role == "teacher" {
h.teachers[c.Addr] = c
log.Printf("[WS] Teacher registered (Address: %s)", c.Addr)
}
}
func (h *WsHub) Unregister(c *SocketClient) {
h.mu.Lock()
defer h.mu.Unlock()
if c.Role == "student" {
delete(h.students, c.StudentID)
log.Printf("[WS] Student %d disconnected", c.StudentID)
// Báo cho các giáo viên đang xem là stream của học sinh đã dừng
if teachers, exists := h.subscribers[c.StudentID]; exists {
for _, tAddr := range teachers {
if t, found := h.teachers[tAddr]; found {
_ = t.Conn.WriteJSON(SocketMsg{
Event: "teacher:stream-stopped",
Data: map[string]any{"studentId": c.StudentID},
})
}
}
delete(h.subscribers, c.StudentID)
}
} else if c.Role == "teacher" {
delete(h.teachers, 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 {
newList := []string{}
for _, addr := range teachersList {
if addr != c.Addr {
newList = append(newList, addr)
}
}
if len(newList) == 0 {
delete(h.subscribers, sID)
// Nếu không còn ai xem học sinh này, gửi lệnh tắt camera/screen cho client học sinh
if student, exists := h.students[sID]; exists {
_ = student.Conn.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
_ = student.Conn.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
}
} else {
h.subscribers[sID] = newList
}
}
}
}
// Teacher bắt đầu xem stream của Student
func (h *WsHub) Subscribe(teacherAddr string, studentID int64) {
h.mu.Lock()
defer h.mu.Unlock()
// Thêm giáo viên vào list người xem của học sinh
teachersList := h.subscribers[studentID]
alreadySubscribed := false
for _, addr := range teachersList {
if addr == teacherAddr {
alreadySubscribed = true
break
}
}
if !alreadySubscribed {
h.subscribers[studentID] = append(teachersList, teacherAddr)
log.Printf("[WS] Teacher %s subscribed to student %d stream", teacherAddr, studentID)
}
// Phát lệnh cho máy học sinh bật stream (nếu học sinh đang online)
if student, exists := h.students[studentID]; exists {
_ = student.Conn.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
_ = student.Conn.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
}
}
// Teacher dừng xem stream của Student
func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
h.mu.Lock()
defer h.mu.Unlock()
teachersList, exists := h.subscribers[studentID]
if !exists {
return
}
newList := []string{}
for _, addr := range teachersList {
if addr != teacherAddr {
newList = append(newList, addr)
}
}
if len(newList) == 0 {
delete(h.subscribers, studentID)
log.Printf("[WS] Student %d has no more proctor subscribers. Stopping streams.", studentID)
// Báo học sinh tắt camera & screen stream để tiết kiệm mạng và CPU
if student, exists := h.students[studentID]; exists {
_ = student.Conn.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
_ = student.Conn.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
}
} else {
h.subscribers[studentID] = newList
}
}
// Chuyển tiếp frame ảnh từ Student đến các Teacher đã subscribe
func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
h.mu.RLock()
defer h.mu.RUnlock()
teachersList, exists := h.subscribers[studentID]
if !exists || len(teachersList) == 0 {
return
}
relayEvent := "teacher:screenshot-stream-frame"
if event == "webcam_stream_frame" {
relayEvent = "teacher:webcam-stream-frame"
}
msg := SocketMsg{
Event: relayEvent,
Data: map[string]any{
"studentId": studentID,
"imageBuffer": data["imageBuffer"],
},
}
for _, addr := range teachersList {
if t, found := h.teachers[addr]; found {
_ = t.Conn.WriteJSON(msg)
}
}
}
// WebSocket handler cho Fiber route
func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
return func(c *websocket.Conn) {
role := c.Query("role", "student")
studentIDStr := c.Query("studentId", "0")
classIDStr := c.Query("classId", "0")
studentID, _ := strconv.ParseInt(studentIDStr, 10, 64)
classID, _ := strconv.ParseInt(classIDStr, 10, 64)
if role == "student" && db != nil {
resolvedClassID := internalDb.FindActiveClassForStudent(db, studentID)
if resolvedClassID > 0 {
classID = resolvedClassID
}
}
client := &SocketClient{
Conn: c,
StudentID: studentID,
ClassID: classID,
Role: role,
}
Hub.Register(client)
defer func() {
Hub.Unregister(client)
c.Close()
}()
for {
_, msgBytes, err := c.ReadMessage()
if err != nil {
break
}
var msg SocketMsg
if err := json.Unmarshal(msgBytes, &msg); err != nil {
continue
}
// Xử lý các sự kiện
switch msg.Event {
case "screenshot_stream_frame", "webcam_stream_frame":
// Nhận frame từ học sinh, chuyển tiếp về các thầy cô
Hub.RelayFrame(client.StudentID, msg.Event, msg.Data)
case "teacher:subscribe":
// Giáo viên đăng ký xem học sinh cụ thể
if client.Role == "teacher" {
if sIDVal, ok := msg.Data["studentId"]; ok {
var sID int64
switch v := sIDVal.(type) {
case float64:
sID = int64(v)
case string:
sID, _ = strconv.ParseInt(v, 10, 64)
}
if sID > 0 {
Hub.Subscribe(client.Addr, sID)
}
}
}
case "teacher:unsubscribe":
// Giáo viên hủy đăng ký
if client.Role == "teacher" {
if sIDVal, ok := msg.Data["studentId"]; ok {
var sID int64
switch v := sIDVal.(type) {
case float64:
sID = int64(v)
case string:
sID, _ = strconv.ParseInt(v, 10, 64)
}
if sID > 0 {
Hub.Unsubscribe(client.Addr, sID)
}
}
}
}
}
}
}

View File

@@ -8,26 +8,25 @@ import (
"server/internal/handlers"
"server/internal/qldt"
"server/internal/syncjobs"
internalWs "server/internal/websocket"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
gofiberWs "github.com/gofiber/websocket/v2"
"github.com/joho/godotenv"
)
func main() {
// Nạp file env nếu có
if err := godotenv.Load(); err != nil {
log.Println("Note: .env file not found or couldn't be loaded, using system environments.")
}
// Đọc cấu hình database
dbHost := getEnv("DB_HOST", "127.0.0.1")
dbPort := getEnv("DB_PORT", "3306")
dbUser := getEnv("DB_USER", "naruto")
dbPass := getEnv("DB_PASSWORD", "29121999aA@")
dbName := getEnv("DB_NAME", "simple_care")
// Đọc token và link QLĐT
qldtToken := getEnv("QLDT_TOKEN", "")
qldtBaseURL := getEnv("QLDT_BASE_URL", "https://apiportal.rikkei.edu.vn")
qldtOrigin := getEnv("QLDT_ORIGIN", "https://qldt.rikkei.edu.vn")
@@ -36,7 +35,6 @@ func main() {
log.Println("WARNING: QLDT_TOKEN is empty! Sync features will fail until QLDT_TOKEN is configured in environment.")
}
// 1. Kết nối DB & Chạy Migration
gormDB, err := db.ConnectDB(dbHost, dbPort, dbUser, dbPass, dbName)
if err != nil {
log.Fatalf("Database connection failed: %v", err)
@@ -46,24 +44,32 @@ func main() {
log.Fatalf("Database migration failed: %v", err)
}
// 2. Tạo Client QLĐT & Sync Jobs
qldtClient := qldt.NewClient(qldtBaseURL, qldtOrigin)
classesJob := syncjobs.NewClassesSyncJob()
studentsJob := syncjobs.NewStudentsSyncJob()
// 3. Khởi tạo Fiber Web Server
app := fiber.New(fiber.Config{
AppName: "Simple Care Sync Backend",
})
// Bật CORS để Client có thể gọi API mà không bị chặn
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, PATCH, PUT, DELETE, OPTIONS",
}))
// 4. Setup Routes
// WebSocket Upgrade Check
app.Use("/ws", func(c *fiber.Ctx) error {
if gofiberWs.IsWebSocketUpgrade(c) {
c.Locals("allowed", true)
return c.Next()
}
return fiber.ErrUpgradeRequired
})
// WebSocket Route
app.Get("/ws", gofiberWs.New(internalWs.WebSocketHandler(gormDB)))
api := app.Group("/api")
// Dashboard Stats
@@ -71,6 +77,8 @@ func main() {
// Classes endpoints
api.Get("/classes", handlers.ListClassesHandler(gormDB))
api.Get("/classes/schedules", handlers.ListActiveSchedulesHandler(gormDB))
api.Get("/classes/:rkId", handlers.GetClassHandler(gormDB))
api.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB))
api.Get("/classes/:rkId/students", handlers.ListClassStudentsHandler(gormDB))
@@ -83,7 +91,36 @@ func main() {
api.Post("/sync/students/start", handlers.StartStudentsSyncHandler(gormDB, qldtClient, studentsJob, qldtToken))
api.Get("/sync/students/status", handlers.GetStudentsSyncStatusHandler(studentsJob))
// Chạy app ở port 8080
// Learning Management endpoints
api.Get("/classes/:rkId/schedule", handlers.GetClassScheduleHandler(gormDB))
api.Post("/classes/:rkId/schedule", handlers.SaveClassScheduleHandler(gormDB))
api.Delete("/classes/:rkId/schedule", handlers.DeleteClassScheduleHandler(gormDB))
api.Get("/classes/:rkId/allowed-apps", handlers.GetAllowedAppsHandler(gormDB))
api.Post("/classes/:rkId/allowed-apps", handlers.SaveAllowedAppsHandler(gormDB))
api.Get("/app-pool", handlers.ListAppPoolHandler(gormDB))
api.Get("/wifi-pool", handlers.ListWifiPoolHandler(gormDB))
api.Get("/network/accepted-wifis", handlers.ListAcceptedWifisHandler(gormDB))
api.Post("/network/accepted-wifis", handlers.SaveAcceptedWifisHandler(gormDB))
api.Post("/network/accepted-wifis/add", handlers.AddAcceptedWifiHandler(gormDB))
api.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(gormDB))
api.Get("/classes/:rkId/session-logs", handlers.ListClassSessionLogsHandler(gormDB))
api.Get("/classes/:rkId/online-students", handlers.GetOnlineStudentsHandler(gormDB))
api.Get("/classes/:rkId/courses", handlers.GetClassCoursesHandler(gormDB, qldtClient, qldtToken))
api.Post("/classes/:rkId/schedule/apply-template", handlers.ApplyScheduleTemplateHandler(gormDB))
api.Get("/classes/:rkId/attendance", handlers.GetClassAttendanceHandler(gormDB))
api.Get("/classes/:rkId/attendance/shifts", handlers.ListAttendanceShiftsHandler(gormDB))
api.Put("/classes/:rkId/attendance/status", handlers.UpdateAttendanceStatusHandler(gormDB))
api.Post("/classes/:rkId/attendance/push-qldt", handlers.PushAttendanceToQLDTHandler(gormDB, qldtClient, qldtToken))
// Student syncing offline-resilient log
api.Post("/student/sync-log", handlers.SyncStudentSessionLogHandler(gormDB))
api.Get("/student/status", handlers.GetStudentStatusHandler(gormDB))
api.Get("/student/wifi-policy", handlers.GetStudentWifiPolicyHandler(gormDB))
api.Post("/student/report-wifi", handlers.ReportWifiHandler(gormDB))
api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB))
port := getEnv("PORT", "8080")
log.Printf("Server starting on port %s...", port)
if err := app.Listen(":" + port); err != nil {

BIN
server/server.exe Normal file

Binary file not shown.