core v1
This commit is contained in:
3
client/.gitignore
vendored
Normal file
3
client/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
build/bin
|
||||
node_modules
|
||||
frontend/dist
|
||||
19
client/README.md
Normal file
19
client/README.md
Normal 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
921
client/app.go
Normal 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
62
client/build.ps1
Normal 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
35
client/build/README.md
Normal 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
BIN
client/build/appicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
68
client/build/darwin/Info.dev.plist
Normal file
68
client/build/darwin/Info.dev.plist
Normal 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>
|
||||
63
client/build/darwin/Info.plist
Normal file
63
client/build/darwin/Info.plist
Normal 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>
|
||||
BIN
client/build/windows/icon.ico
Normal file
BIN
client/build/windows/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
15
client/build/windows/info.json
Normal file
15
client/build/windows/info.json
Normal 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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
114
client/build/windows/installer/project.nsi
Normal file
114
client/build/windows/installer/project.nsi
Normal 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
|
||||
249
client/build/windows/installer/wails_tools.nsh
Normal file
249
client/build/windows/installer/wails_tools.nsh
Normal 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
|
||||
15
client/build/windows/wails.exe.manifest
Normal file
15
client/build/windows/wails.exe.manifest
Normal 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
BIN
client/client.exe
Normal file
Binary file not shown.
12
client/frontend/index.html
Normal file
12
client/frontend/index.html
Normal 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
664
client/frontend/package-lock.json
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
client/frontend/package.json
Normal file
13
client/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
client/frontend/package.json.md5
Normal file
1
client/frontend/package.json.md5
Normal file
@@ -0,0 +1 @@
|
||||
5fbf12469d224a93954efecb5886e8a6
|
||||
691
client/frontend/src/app.css
Normal file
691
client/frontend/src/app.css
Normal 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); }
|
||||
}
|
||||
93
client/frontend/src/assets/fonts/OFL.txt
Normal file
93
client/frontend/src/assets/fonts/OFL.txt
Normal 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.
|
||||
BIN
client/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
Normal file
BIN
client/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
Normal file
Binary file not shown.
BIN
client/frontend/src/assets/images/logo-universal.png
Normal file
BIN
client/frontend/src/assets/images/logo-universal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
368
client/frontend/src/main.js
Normal file
368
client/frontend/src/main.js
Normal 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();
|
||||
14
client/frontend/src/style.css
Normal file
14
client/frontend/src/style.css
Normal file
@@ -0,0 +1,14 @@
|
||||
html {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
14
client/frontend/wailsjs/go/main/App.d.ts
vendored
Normal file
14
client/frontend/wailsjs/go/main/App.d.ts
vendored
Normal 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>;
|
||||
27
client/frontend/wailsjs/go/main/App.js
Normal file
27
client/frontend/wailsjs/go/main/App.js
Normal 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);
|
||||
}
|
||||
24
client/frontend/wailsjs/runtime/package.json
Normal file
24
client/frontend/wailsjs/runtime/package.json
Normal 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"
|
||||
}
|
||||
330
client/frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
330
client/frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal 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>;
|
||||
298
client/frontend/wailsjs/runtime/runtime.js
Normal file
298
client/frontend/wailsjs/runtime/runtime.js
Normal 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
44
client/go.mod
Normal 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
92
client/go.sum
Normal 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=
|
||||
317
client/internal/blocker/blocker.go
Normal file
317
client/internal/blocker/blocker.go
Normal 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.")
|
||||
}
|
||||
35
client/internal/screen/screen.go
Normal file
35
client/internal/screen/screen.go
Normal 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
|
||||
}
|
||||
82
client/internal/winapi/winapi.go
Normal file
82
client/internal/winapi/winapi.go
Normal 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
36
client/main.go
Normal 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
13
client/wails.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 lý 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 lý 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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
167
management/src/components/AppPoolModal.tsx
Normal file
167
management/src/components/AppPoolModal.tsx
Normal 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">Đã có</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>
|
||||
);
|
||||
};
|
||||
163
management/src/components/AttendancePanel.tsx
Normal file
163
management/src/components/AttendancePanel.tsx
Normal 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 có 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>
|
||||
);
|
||||
};
|
||||
547
management/src/components/ClassWorkspace.tsx
Normal file
547
management/src/components/ClassWorkspace.tsx
Normal 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ĩ 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 và 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')}
|
||||
>
|
||||
Sơ đồ học viên
|
||||
</button>
|
||||
<button
|
||||
className={`tab-sub-btn ${activeSubTab === 'logs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSubTab('logs')}
|
||||
>
|
||||
Nhật ký 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 ký đ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 có nhật ký 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>
|
||||
);
|
||||
};
|
||||
@@ -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ý 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' }}>
|
||||
Mã lớp: {activeClass.classCode} | Sĩ số: {activeClass.studentCount}
|
||||
</p>
|
||||
</div>
|
||||
<button className="modal-close-btn" onClick={() => setActiveClass(null)}>
|
||||
×
|
||||
</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 có 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>Mã 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>
|
||||
|
||||
@@ -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ộ và quản lý 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 và danh sách sinh viên nội bộ mà không lo ảnh hưởng đến dữ liệu hiện có.
|
||||
<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 và 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ý Lớp học
|
||||
Quản lý 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 và 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ý Lớp:</b> Lọc danh sách lớp theo Phân hệ (System ID), Tìm kiếm tên hoặc mã lớp và 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 và thi cử.</li>
|
||||
<li><strong>Đồng bộ lớp học:</strong> Tải danh sách lớp và 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ý lớp:</strong> Lọc theo phân hệ, tìm kiếm tên/mã lớp và 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>
|
||||
);
|
||||
};
|
||||
|
||||
424
management/src/components/LearningTab.tsx
Normal file
424
management/src/components/LearningTab.tsx
Normal 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ý 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 và 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 có 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>Mã 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 có 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 có 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 Ký & 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>Mã 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 có 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>
|
||||
);
|
||||
};
|
||||
35
management/src/components/LiveProctorModal.tsx
Normal file
35
management/src/components/LiveProctorModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
111
management/src/components/NavHistoryBar.tsx
Normal file
111
management/src/components/NavHistoryBar.tsx
Normal 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);
|
||||
}
|
||||
223
management/src/components/NetworkTab.tsx
Normal file
223
management/src/components/NetworkTab.tsx
Normal 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 lý 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 có đ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 cô 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 và 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>
|
||||
);
|
||||
};
|
||||
224
management/src/components/ProctorStreamPanels.tsx
Normal file
224
management/src/components/ProctorStreamPanels.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
244
management/src/components/ScheduleEditor.tsx
Normal file
244
management/src/components/ScheduleEditor.tsx
Normal 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ứ 2–6)? 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 (T2–T6)'}
|
||||
</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 có 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>
|
||||
);
|
||||
};
|
||||
50
management/src/components/StudentAvatar.tsx
Normal file
50
management/src/components/StudentAvatar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
111
management/src/components/StudentDetailModal.tsx
Normal file
111
management/src/components/StudentDetailModal.tsx
Normal 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ỉ có khi app Simple Care đang chạy.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showProctor && (
|
||||
<div className="student-detail-proctor">
|
||||
<ProctorStreamPanels studentId={student.rkId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
168
management/src/components/WifiPoolModal.tsx
Normal file
168
management/src/components/WifiPoolModal.tsx
Normal 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">Đã có</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>
|
||||
);
|
||||
};
|
||||
4
management/src/constants.ts
Normal file
4
management/src/constants.ts
Normal 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
112
management/src/navigation.ts
Normal file
112
management/src/navigation.ts
Normal 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'));
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
QLDT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBodW9jbnRiQHJpa2tlaWFjYWRlbXkuY29tIiwibmFtZSI6Ik5ndXnhu4VuIFRoYW5oIELDrG5oIFBoxrDhu5tjIiwiaWQiOjI0LCJyb2xlIjpbeyJpZCI6MSwibmFtZSI6IkFETUlOIn0seyJpZCI6MywibmFtZSI6IlRFQUNIRVIifV0sInR5cGUiOiJ1c2VyIiwiaWF0IjoxNzgyNjg5MjY5LCJleHAiOjE3ODI3NzU2Njl9.0DDBte7Mm9TJhMRs5cvqBkBK-YPi-79XB-qWcrzC-SI
|
||||
QLDT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBodW9jbnRiQHJpa2tlaWFjYWRlbXkuY29tIiwibmFtZSI6Ik5ndXnhu4VuIFRoYW5oIELDrG5oIFBoxrDhu5tjIiwiaWQiOjI0LCJyb2xlIjpbeyJpZCI6MSwibmFtZSI6IkFETUlOIn0seyJpZCI6MywibmFtZSI6IlRFQUNIRVIifV0sInR5cGUiOiJ1c2VyIiwiaWF0IjoxNzgyNzc3NzEzLCJleHAiOjE3ODI4NjQxMTN9.bj4nMPYZWVIsuiuA3XHLtc8yFFwAC9MDlPx-FnIM6z4
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
4
server/internal/constants/apps.go
Normal file
4
server/internal/constants/apps.go
Normal 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"
|
||||
96
server/internal/db/active_class.go
Normal file
96
server/internal/db/active_class.go
Normal 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
|
||||
}
|
||||
85
server/internal/db/courses.go
Normal file
85
server/internal/db/courses.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
462
server/internal/handlers/handlers_attendance.go
Normal file
462
server/internal/handlers/handlers_attendance.go
Normal 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})
|
||||
}
|
||||
}
|
||||
629
server/internal/handlers/handlers_learning.go
Normal file
629
server/internal/handlers/handlers_learning.go
Normal 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})
|
||||
}
|
||||
}
|
||||
235
server/internal/handlers/handlers_network.go
Normal file
235
server/internal/handlers/handlers_network.go
Normal 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})
|
||||
}
|
||||
}
|
||||
212
server/internal/handlers/handlers_student.go
Normal file
212
server/internal/handlers/handlers_student.go
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
257
server/internal/qldt/attendance.go
Normal file
257
server/internal/qldt/attendance.go
Normal 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")
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
28
server/internal/util/avatar.go
Normal file
28
server/internal/util/avatar.go
Normal 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 ""
|
||||
}
|
||||
286
server/internal/websocket/websocket.go
Normal file
286
server/internal/websocket/websocket.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
BIN
server/server.exe
Normal file
Binary file not shown.
Reference in New Issue
Block a user