tam chat
This commit is contained in:
181
client/app.go
181
client/app.go
@@ -104,6 +104,8 @@ type App struct {
|
|||||||
wifiRejected bool
|
wifiRejected bool
|
||||||
quitDialogShown bool
|
quitDialogShown bool
|
||||||
monitoringTornDown bool
|
monitoringTornDown bool
|
||||||
|
chatUnread int
|
||||||
|
replyStaffID uint
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocalStats struct {
|
type LocalStats struct {
|
||||||
@@ -274,10 +276,7 @@ func (a *App) startLocalServer() {
|
|||||||
blocker.Instance.Start()
|
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
|
// 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.refreshStudentData()
|
||||||
go a.fetchStudentStatus(st.StudentID)
|
|
||||||
go a.fetchWifiPolicy()
|
|
||||||
go a.refreshNetworkStatus()
|
|
||||||
|
|
||||||
// Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets
|
// Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets
|
||||||
runtime.WindowReloadApp(a.ctx)
|
runtime.WindowReloadApp(a.ctx)
|
||||||
@@ -315,10 +314,8 @@ func (a *App) loadSession() {
|
|||||||
log.Printf("[APP] Loaded local session: %s (%s)", s.FullName, s.StudentCode)
|
log.Printf("[APP] Loaded local session: %s (%s)", s.FullName, s.StudentCode)
|
||||||
blocker.Instance.Start()
|
blocker.Instance.Start()
|
||||||
|
|
||||||
go a.fetchAllowedApps(s.SystemID)
|
go a.refreshStudentData()
|
||||||
go a.fetchStudentStatus(s.StudentID)
|
|
||||||
go a.fetchWifiPolicy()
|
go a.fetchWifiPolicy()
|
||||||
go a.refreshNetworkStatus()
|
|
||||||
go a.syncProfileToServer(&s)
|
go a.syncProfileToServer(&s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -589,6 +586,33 @@ func (a *App) runMonitorTick() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshNetworkStatus — đọc WiFi + ping server ngay (không cộng thời gian online/offline).
|
||||||
|
func (a *App) resolveClassRkID() int64 {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.dashboard.ClassRkID > 0 {
|
||||||
|
return a.dashboard.ClassRkID
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) refreshStudentData() {
|
||||||
|
a.mu.Lock()
|
||||||
|
student := a.student
|
||||||
|
a.mu.Unlock()
|
||||||
|
if student == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.fetchStudentStatus(student.StudentID)
|
||||||
|
classID := a.resolveClassRkID()
|
||||||
|
if classID > 0 {
|
||||||
|
a.fetchAllowedApps(classID)
|
||||||
|
} else {
|
||||||
|
a.fetchAllowedApps(0)
|
||||||
|
}
|
||||||
|
a.refreshNetworkStatus()
|
||||||
|
}
|
||||||
|
|
||||||
// refreshNetworkStatus — đọc WiFi + ping server ngay (không cộng thời gian online/offline).
|
// refreshNetworkStatus — đọc WiFi + ping server ngay (không cộng thời gian online/offline).
|
||||||
func (a *App) refreshNetworkStatus() {
|
func (a *App) refreshNetworkStatus() {
|
||||||
if !a.isMonitoringActive() || !a.CheckLoginStatus() {
|
if !a.isMonitoringActive() || !a.CheckLoginStatus() {
|
||||||
@@ -601,11 +625,14 @@ func (a *App) refreshNetworkStatus() {
|
|||||||
a.wifiBSSID = wifi.BSSID
|
a.wifiBSSID = wifi.BSSID
|
||||||
a.backendOnline = backendOnline
|
a.backendOnline = backendOnline
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
if backendOnline {
|
||||||
|
a.connectWS()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) pingBackend() bool {
|
func (a *App) pingBackend() bool {
|
||||||
client := http.Client{Timeout: 3 * time.Second}
|
client := http.Client{Timeout: 3 * time.Second}
|
||||||
resp, err := client.Get("http://127.0.0.1:8080/api/stats")
|
resp, err := client.Get("http://127.0.0.1:8080/api/health")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -628,12 +655,18 @@ func (a *App) syncLogsToServer() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go a.fetchStudentStatus(student.StudentID)
|
go func() {
|
||||||
go a.fetchAllowedApps(student.SystemID)
|
a.fetchStudentStatus(student.StudentID)
|
||||||
|
classID := a.resolveClassRkID()
|
||||||
|
if classID <= 0 {
|
||||||
|
classID = student.SystemID
|
||||||
|
}
|
||||||
|
a.fetchAllowedApps(classID)
|
||||||
|
}()
|
||||||
|
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"studentRkId": student.StudentID,
|
"studentRkId": student.StudentID,
|
||||||
"classRkId": student.SystemID,
|
"classRkId": a.resolveClassRkID(),
|
||||||
"sessionDate": time.Now().Format("2006-01-02"),
|
"sessionDate": time.Now().Format("2006-01-02"),
|
||||||
"addOnlineSeconds": unsyncedOn,
|
"addOnlineSeconds": unsyncedOn,
|
||||||
"addOfflineSeconds": unsyncedOff,
|
"addOfflineSeconds": unsyncedOff,
|
||||||
@@ -809,8 +842,9 @@ func (a *App) fetchAllowedApps(classId int64) {
|
|||||||
}
|
}
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
url := fmt.Sprintf("http://127.0.0.1:8080/api/classes/%d/allowed-apps?studentId=%d", classId, studentID)
|
||||||
client := http.Client{Timeout: 4 * time.Second}
|
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))
|
resp, err := client.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -888,7 +922,7 @@ func (a *App) fetchStudentStatus(studentID int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) connectWS() {
|
func (a *App) connectWS() {
|
||||||
if !a.isMonitoringActive() {
|
if !a.CheckLoginStatus() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
@@ -897,9 +931,13 @@ func (a *App) connectWS() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
student := a.student
|
student := a.student
|
||||||
|
classID := a.dashboard.ClassRkID
|
||||||
|
if classID <= 0 {
|
||||||
|
classID = student.SystemID
|
||||||
|
}
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
wsUrl := fmt.Sprintf("ws://127.0.0.1:8080/ws?role=student&studentId=%d&classId=%d", student.StudentID, student.SystemID)
|
wsUrl := fmt.Sprintf("ws://127.0.0.1:8080/ws?role=student&studentId=%d&classId=%d", student.StudentID, classID)
|
||||||
dialer := websocket.Dialer{HandshakeTimeout: 4 * time.Second}
|
dialer := websocket.Dialer{HandshakeTimeout: 4 * time.Second}
|
||||||
conn, _, err := dialer.Dial(wsUrl, nil)
|
conn, _, err := dialer.Dial(wsUrl, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -934,6 +972,20 @@ func (a *App) connectWS() {
|
|||||||
runtime.EventsEmit(a.ctx, "start_webcam_stream")
|
runtime.EventsEmit(a.ctx, "start_webcam_stream")
|
||||||
case "stop_webcam_stream":
|
case "stop_webcam_stream":
|
||||||
runtime.EventsEmit(a.ctx, "stop_webcam_stream")
|
runtime.EventsEmit(a.ctx, "stop_webcam_stream")
|
||||||
|
case "chat:message":
|
||||||
|
if staffVal, ok := msg.Data["staffId"].(float64); ok {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.replyStaffID = uint(staffVal)
|
||||||
|
a.chatUnread++
|
||||||
|
unread := a.chatUnread
|
||||||
|
a.mu.Unlock()
|
||||||
|
runtime.EventsEmit(a.ctx, "chat:notify", map[string]any{
|
||||||
|
"unread": unread,
|
||||||
|
"preview": msg.Data["body"],
|
||||||
|
"from": msg.Data["staffName"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
runtime.EventsEmit(a.ctx, "chat:message", msg.Data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -1003,3 +1055,104 @@ func (a *App) stopScreenshotStream() {
|
|||||||
close(a.streamScStop)
|
close(a.streamScStop)
|
||||||
log.Println("[WS] Screenshot screen-streaming stopped.")
|
log.Println("[WS] Screenshot screen-streaming stopped.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) GetChatUnread() int {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
return a.chatUnread
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ClearChatUnread() {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.chatUnread = 0
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetChatConversations() ([]map[string]any, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
student := a.student
|
||||||
|
a.mu.Unlock()
|
||||||
|
if student == nil {
|
||||||
|
return nil, errors.New("chưa đăng nhập")
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("http://127.0.0.1:8080/api/student/chat/conversations?studentRkId=%d", student.StudentID)
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var payload struct {
|
||||||
|
Data []map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return payload.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetChatMessages(staffID uint) ([]map[string]any, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
student := a.student
|
||||||
|
a.mu.Unlock()
|
||||||
|
if student == nil {
|
||||||
|
return nil, errors.New("chưa đăng nhập")
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("http://127.0.0.1:8080/api/student/chat/messages?studentRkId=%d&staffId=%d", student.StudentID, staffID)
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var payload struct {
|
||||||
|
Data []map[string]any `json:"data"`
|
||||||
|
ReplyStaffID uint `json:"replyStaffId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if staffID > 0 {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.replyStaffID = staffID
|
||||||
|
a.mu.Unlock()
|
||||||
|
} else if payload.ReplyStaffID > 0 {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.replyStaffID = payload.ReplyStaffID
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
a.ClearChatUnread()
|
||||||
|
return payload.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SendChatMessage(body string) error {
|
||||||
|
body = strings.TrimSpace(body)
|
||||||
|
if body == "" {
|
||||||
|
return errors.New("tin nhắn trống")
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
student := a.student
|
||||||
|
staffID := a.replyStaffID
|
||||||
|
a.mu.Unlock()
|
||||||
|
if student == nil {
|
||||||
|
return errors.New("chưa đăng nhập")
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"studentRkId": student.StudentID,
|
||||||
|
"staffId": staffID,
|
||||||
|
"body": body,
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
resp, err := http.Post("http://127.0.0.1:8080/api/student/chat/messages", "application/json", bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
var errBody map[string]any
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&errBody)
|
||||||
|
if msg, ok := errBody["error"].(string); ok {
|
||||||
|
return errors.New(msg)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("gửi tin thất bại (%d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -437,45 +437,291 @@ body {
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock-display-summary {
|
.card-title-row--shifts {
|
||||||
display: flex;
|
margin-bottom: 0.2rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
gap: 0.45rem;
|
gap: 0.35rem 0.5rem;
|
||||||
margin-bottom: 0.45rem;
|
align-items: center;
|
||||||
padding-bottom: 0.45rem;
|
|
||||||
border-bottom: 1px dashed var(--border-color);
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock-chip {
|
.clock-inline {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
padding: 0.2rem 0.5rem;
|
flex-wrap: wrap;
|
||||||
border-radius: 6px;
|
margin-left: auto;
|
||||||
border: 1px solid var(--border-color);
|
font-size: 0.68rem;
|
||||||
background: var(--bg-subtle);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clock-chip em {
|
|
||||||
font-style: normal;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock-chip strong {
|
.clock-inline-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-inline-item strong {
|
||||||
font-family: 'Share Tech Mono', monospace;
|
font-family: 'Share Tech Mono', monospace;
|
||||||
font-size: 0.78rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock-chip--online strong {
|
.clock-inline-item strong.online { color: var(--online-color); }
|
||||||
color: var(--online-color);
|
.clock-inline-item strong.offline { color: var(--offline-color); }
|
||||||
|
|
||||||
|
.clock-inline-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock-chip--offline strong {
|
.clock-inline-dot.online { background: var(--online-color); }
|
||||||
color: var(--offline-color);
|
.clock-inline-dot.offline { background: var(--offline-color); }
|
||||||
|
|
||||||
|
.clock-inline-sep {
|
||||||
|
opacity: 0.35;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title-row--shifts .card-title-sub {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Student chat messenger */
|
||||||
|
.student-chat-dock {
|
||||||
|
position: fixed;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-toast {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 64px;
|
||||||
|
width: 260px;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #1e293b;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-fab {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: -2px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-messenger {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 58px;
|
||||||
|
width: min(520px, calc(100vw - 24px));
|
||||||
|
height: min(400px, calc(100vh - 80px));
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-messenger-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-close {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-messenger-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-sidebar {
|
||||||
|
width: 150px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-conv-btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.55rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-conv-btn.active {
|
||||||
|
background: rgba(187, 33, 38, 0.12);
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-conv-name {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-conv-unread {
|
||||||
|
font-style: normal;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-conv-preview {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-thread {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-thread-head {
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.55rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-empty {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-bubble {
|
||||||
|
max-width: 88%;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-bubble--staff { align-self: flex-start; }
|
||||||
|
.student-chat-bubble--student { align-self: flex-end; }
|
||||||
|
|
||||||
|
.student-chat-label {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-body {
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-bubble--staff .student-chat-body {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-bubble--student .student-chat-body {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-time {
|
||||||
|
font-size: 0.58rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-compose {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-compose input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-chat-compose input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shifts-wrap {
|
.shifts-wrap {
|
||||||
@@ -622,15 +868,15 @@ body {
|
|||||||
.clocks-card {
|
.clocks-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.3rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0.75rem 0.85rem;
|
padding: 0.5rem 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 0.85rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
letter-spacing: -0.2px;
|
letter-spacing: -0.2px;
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ let stats = {
|
|||||||
shifts: []
|
shifts: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let chatOpen = false;
|
||||||
|
let chatMessages = [];
|
||||||
|
let chatConversations = [];
|
||||||
|
let activeStaffId = 0;
|
||||||
|
|
||||||
// Quản lý webcam
|
// Quản lý webcam
|
||||||
let webcamStream = null;
|
let webcamStream = null;
|
||||||
let webcamInterval = null;
|
let webcamInterval = null;
|
||||||
@@ -46,6 +51,15 @@ function init() {
|
|||||||
|
|
||||||
window.runtime.EventsOn('start_webcam_stream', startWebcam);
|
window.runtime.EventsOn('start_webcam_stream', startWebcam);
|
||||||
window.runtime.EventsOn('stop_webcam_stream', stopWebcam);
|
window.runtime.EventsOn('stop_webcam_stream', stopWebcam);
|
||||||
|
window.runtime.EventsOn('chat:message', () => {
|
||||||
|
updateChatBadge();
|
||||||
|
loadChatConversations().catch(console.error);
|
||||||
|
if (chatOpen && activeStaffId) loadChatMessages(activeStaffId).catch(console.error);
|
||||||
|
});
|
||||||
|
window.runtime.EventsOn('chat:notify', (data) => {
|
||||||
|
updateChatBadge();
|
||||||
|
showChatToast(data);
|
||||||
|
});
|
||||||
checkLogin();
|
checkLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +71,8 @@ async function checkLogin() {
|
|||||||
studentInfo = await window.go.main.App.GetStudentInfo();
|
studentInfo = await window.go.main.App.GetStudentInfo();
|
||||||
renderDashboard();
|
renderDashboard();
|
||||||
startStatsTicker();
|
startStatsTicker();
|
||||||
|
ensureChatWidget();
|
||||||
|
updateChatBadge();
|
||||||
} else {
|
} else {
|
||||||
loggedIn = false;
|
loggedIn = false;
|
||||||
renderLoginPrompt();
|
renderLoginPrompt();
|
||||||
@@ -226,19 +242,14 @@ function renderDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="clocks-card card">
|
<div class="clocks-card card">
|
||||||
<div class="card-title-row">
|
<div class="card-title-row card-title-row--shifts">
|
||||||
<div class="card-title">Hôm nay — theo ca</div>
|
<div class="card-title">Hôm nay — theo ca</div>
|
||||||
<div class="card-title-sub" id="session-date">${stats.sessionDate || ''}</div>
|
<div class="clock-inline">
|
||||||
|
<span class="clock-inline-item" title="Tổng trực tuyến"><span class="clock-inline-dot online"></span><strong id="clock-online" class="online">00:00:00</strong></span>
|
||||||
|
<span class="clock-inline-sep">·</span>
|
||||||
|
<span class="clock-inline-item" title="Tổng ngoại tuyến"><span class="clock-inline-dot offline"></span><strong id="clock-offline" class="offline">00:00:00</strong></span>
|
||||||
|
<span class="card-title-sub" id="session-date">${stats.sessionDate || ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="clock-display clock-display-summary">
|
|
||||||
<span class="clock-chip clock-chip--online">
|
|
||||||
<em>Trực tuyến</em>
|
|
||||||
<strong id="clock-online">00:00:00</strong>
|
|
||||||
</span>
|
|
||||||
<span class="clock-chip clock-chip--offline">
|
|
||||||
<em>Ngoại tuyến</em>
|
|
||||||
<strong id="clock-offline">00:00:00</strong>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="shifts-wrap" id="shifts-wrap">
|
<div class="shifts-wrap" id="shifts-wrap">
|
||||||
${renderShiftsTable(stats.shifts)}
|
${renderShiftsTable(stats.shifts)}
|
||||||
@@ -254,11 +265,177 @@ function renderDashboard() {
|
|||||||
loggedIn = false;
|
loggedIn = false;
|
||||||
studentInfo = null;
|
studentInfo = null;
|
||||||
stopWebcam();
|
stopWebcam();
|
||||||
|
const w = document.getElementById('student-chat-widget');
|
||||||
|
if (w) w.remove();
|
||||||
await window.go.main.App.Logout();
|
await window.go.main.App.Logout();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureChatWidget() {
|
||||||
|
if (document.getElementById('student-chat-widget')) return;
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.id = 'student-chat-widget';
|
||||||
|
wrap.className = 'student-chat-dock';
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="student-chat-toast" id="student-chat-toast" hidden></div>
|
||||||
|
<button type="button" class="student-chat-fab" id="student-chat-fab" title="Tin nhắn">
|
||||||
|
💬<span class="student-chat-badge" id="student-chat-badge" hidden>0</span>
|
||||||
|
</button>
|
||||||
|
<div class="student-chat-messenger" id="student-chat-messenger" hidden>
|
||||||
|
<header class="student-chat-messenger-head">
|
||||||
|
<strong>Tin nhắn</strong>
|
||||||
|
<button type="button" class="student-chat-close" id="student-chat-close">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="student-chat-messenger-body">
|
||||||
|
<aside class="student-chat-sidebar" id="student-chat-conv-list"></aside>
|
||||||
|
<section class="student-chat-thread">
|
||||||
|
<div class="student-chat-thread-head" id="student-chat-thread-head">Chọn hội thoại</div>
|
||||||
|
<div class="student-chat-messages" id="student-chat-messages"></div>
|
||||||
|
<div class="student-chat-compose">
|
||||||
|
<input id="student-chat-input" placeholder="Nhập tin nhắn..." disabled />
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="student-chat-send" disabled>Gửi</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(wrap);
|
||||||
|
document.getElementById('student-chat-fab').addEventListener('click', toggleChat);
|
||||||
|
document.getElementById('student-chat-close').addEventListener('click', () => setChatOpen(false));
|
||||||
|
document.getElementById('student-chat-send').addEventListener('click', sendStudentChat);
|
||||||
|
document.getElementById('student-chat-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); sendStudentChat(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showChatToast(data) {
|
||||||
|
const toast = document.getElementById('student-chat-toast');
|
||||||
|
if (!toast) return;
|
||||||
|
const from = data?.from || 'Giảng viên';
|
||||||
|
const preview = data?.preview || 'Bạn có tin nhắn mới';
|
||||||
|
toast.innerHTML = `<strong>${escapeHtml(from)}</strong><span>${escapeHtml(preview)}</span>`;
|
||||||
|
toast.hidden = false;
|
||||||
|
clearTimeout(showChatToast._t);
|
||||||
|
showChatToast._t = setTimeout(() => { toast.hidden = true; }, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChatOpen(open) {
|
||||||
|
chatOpen = open;
|
||||||
|
const panel = document.getElementById('student-chat-messenger');
|
||||||
|
if (panel) panel.hidden = !open;
|
||||||
|
if (open) {
|
||||||
|
loadChatConversations().catch(console.error);
|
||||||
|
if (activeStaffId) loadChatMessages(activeStaffId).catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChat() {
|
||||||
|
setChatOpen(!chatOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChatConversations() {
|
||||||
|
const rows = await window.go.main.App.GetChatConversations();
|
||||||
|
chatConversations = Array.isArray(rows) ? rows : [];
|
||||||
|
renderChatConversations();
|
||||||
|
updateChatBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChatConversations() {
|
||||||
|
const list = document.getElementById('student-chat-conv-list');
|
||||||
|
if (!list) return;
|
||||||
|
if (!chatConversations.length) {
|
||||||
|
list.innerHTML = '<div class="student-chat-empty">Chưa có tin nhắn</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = chatConversations.map((c) => {
|
||||||
|
const sid = Number(c.staffId || 0);
|
||||||
|
const unread = Number(c.unread || 0);
|
||||||
|
const active = sid === activeStaffId ? ' active' : '';
|
||||||
|
const name = escapeHtml(c.staffName || c.staffEmail || 'Giảng viên');
|
||||||
|
const preview = escapeHtml(c.lastMessage || '');
|
||||||
|
return `<button type="button" class="student-chat-conv-btn${active}" data-staff-id="${sid}">
|
||||||
|
<span class="student-chat-conv-name">${name}${unread > 0 ? `<em class="student-chat-conv-unread">${unread}</em>` : ''}</span>
|
||||||
|
<span class="student-chat-conv-preview">${preview}</span>
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
list.querySelectorAll('.student-chat-conv-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const sid = Number(btn.getAttribute('data-staff-id'));
|
||||||
|
pickStaffChat(sid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickStaffChat(staffId) {
|
||||||
|
activeStaffId = staffId;
|
||||||
|
const head = document.getElementById('student-chat-thread-head');
|
||||||
|
const conv = chatConversations.find((c) => Number(c.staffId) === staffId);
|
||||||
|
if (head) head.textContent = conv?.staffName || conv?.staffEmail || 'Giảng viên';
|
||||||
|
const input = document.getElementById('student-chat-input');
|
||||||
|
const sendBtn = document.getElementById('student-chat-send');
|
||||||
|
if (input) input.disabled = !staffId;
|
||||||
|
if (sendBtn) sendBtn.disabled = !staffId;
|
||||||
|
renderChatConversations();
|
||||||
|
if (staffId) await loadChatMessages(staffId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChatMessages(staffId) {
|
||||||
|
const msgs = await window.go.main.App.GetChatMessages(staffId);
|
||||||
|
chatMessages = Array.isArray(msgs) ? msgs : [];
|
||||||
|
renderChatMessages();
|
||||||
|
updateChatBadge();
|
||||||
|
loadChatConversations().catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChatMessages() {
|
||||||
|
const box = document.getElementById('student-chat-messages');
|
||||||
|
if (!box) return;
|
||||||
|
if (!chatMessages.length) {
|
||||||
|
box.innerHTML = '<div class="student-chat-empty">Chưa có tin nhắn</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.innerHTML = chatMessages.map((m) => {
|
||||||
|
const role = m.senderRole === 'student' ? 'student' : 'staff';
|
||||||
|
const time = m.createdAt ? new Date(m.createdAt).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' }) : '';
|
||||||
|
const label = role === 'staff' ? (m.staffName || 'Giảng viên') : 'Bạn';
|
||||||
|
return `<div class="student-chat-bubble student-chat-bubble--${role}"><div class="student-chat-label">${label}</div><div class="student-chat-body">${escapeHtml(m.body || '')}</div><div class="student-chat-time">${time}</div></div>`;
|
||||||
|
}).join('');
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendStudentChat() {
|
||||||
|
const input = document.getElementById('student-chat-input');
|
||||||
|
if (!input || !input.value.trim() || !activeStaffId) return;
|
||||||
|
try {
|
||||||
|
await window.go.main.App.SendChatMessage(input.value.trim());
|
||||||
|
input.value = '';
|
||||||
|
await loadChatMessages(activeStaffId);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err?.message || err || 'Gửi tin thất bại');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateChatBadge() {
|
||||||
|
const badge = document.getElementById('student-chat-badge');
|
||||||
|
if (!badge || !window.go?.main?.App?.GetChatUnread) return;
|
||||||
|
try {
|
||||||
|
const n = await window.go.main.App.GetChatUnread();
|
||||||
|
if (n > 0) {
|
||||||
|
badge.hidden = false;
|
||||||
|
badge.textContent = n > 9 ? '9+' : String(n);
|
||||||
|
} else {
|
||||||
|
badge.hidden = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatDuration(totalSeconds) {
|
function formatDuration(totalSeconds) {
|
||||||
const hrs = Math.floor(totalSeconds / 3600);
|
const hrs = Math.floor(totalSeconds / 3600);
|
||||||
const mins = Math.floor((totalSeconds % 3600) / 60);
|
const mins = Math.floor((totalSeconds % 3600) / 60);
|
||||||
@@ -330,6 +507,8 @@ function startStatsTicker() {
|
|||||||
if (indicator) {
|
if (indicator) {
|
||||||
indicator.className = `status-indicator ${stats.serverReachable ? 'online' : 'offline'}`;
|
indicator.className = `status-indicator ${stats.serverReachable ? 'online' : 'offline'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateChatBadge();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching stats:', err);
|
console.error('Error fetching stats:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
10
client/frontend/wailsjs/go/main/App.d.ts
vendored
10
client/frontend/wailsjs/go/main/App.d.ts
vendored
@@ -3,6 +3,14 @@
|
|||||||
|
|
||||||
export function CheckLoginStatus():Promise<boolean>;
|
export function CheckLoginStatus():Promise<boolean>;
|
||||||
|
|
||||||
|
export function ClearChatUnread():Promise<void>;
|
||||||
|
|
||||||
|
export function GetChatConversations():Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
|
export function GetChatMessages(arg1:number):Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
|
export function GetChatUnread():Promise<number>;
|
||||||
|
|
||||||
export function GetStats():Promise<Record<string, any>>;
|
export function GetStats():Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function GetStudentInfo():Promise<Record<string, any>>;
|
export function GetStudentInfo():Promise<Record<string, any>>;
|
||||||
@@ -11,4 +19,6 @@ export function Logout():Promise<void>;
|
|||||||
|
|
||||||
export function NavigateToLogin():Promise<void>;
|
export function NavigateToLogin():Promise<void>;
|
||||||
|
|
||||||
|
export function SendChatMessage(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SendWebcamFrame(arg1:string):Promise<void>;
|
export function SendWebcamFrame(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -6,6 +6,22 @@ export function CheckLoginStatus() {
|
|||||||
return window['go']['main']['App']['CheckLoginStatus']();
|
return window['go']['main']['App']['CheckLoginStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ClearChatUnread() {
|
||||||
|
return window['go']['main']['App']['ClearChatUnread']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetChatConversations() {
|
||||||
|
return window['go']['main']['App']['GetChatConversations']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetChatMessages(arg1) {
|
||||||
|
return window['go']['main']['App']['GetChatMessages'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetChatUnread() {
|
||||||
|
return window['go']['main']['App']['GetChatUnread']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetStats() {
|
export function GetStats() {
|
||||||
return window['go']['main']['App']['GetStats']();
|
return window['go']['main']['App']['GetStats']();
|
||||||
}
|
}
|
||||||
@@ -22,6 +38,10 @@ export function NavigateToLogin() {
|
|||||||
return window['go']['main']['App']['NavigateToLogin']();
|
return window['go']['main']['App']['NavigateToLogin']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SendChatMessage(arg1) {
|
||||||
|
return window['go']['main']['App']['SendChatMessage'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SendWebcamFrame(arg1) {
|
export function SendWebcamFrame(arg1) {
|
||||||
return window['go']['main']['App']['SendWebcamFrame'](arg1);
|
return window['go']['main']['App']['SendWebcamFrame'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,19 +182,63 @@ func EnumerateGUIWindows() ([]WindowInfo, error) {
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseKeywordList(keywords string) []string {
|
||||||
|
keywords = strings.ReplaceAll(keywords, "\r\n", "\n")
|
||||||
|
parts := strings.FieldsFunc(keywords, func(r rune) bool {
|
||||||
|
return r == ',' || r == '\n' || r == ';' || r == '|'
|
||||||
|
})
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var list []string
|
||||||
|
for _, p := range parts {
|
||||||
|
trimmed := strings.TrimSpace(strings.ToLower(p))
|
||||||
|
trimmed = strings.TrimSuffix(trimmed, ".exe")
|
||||||
|
if trimmed == "" || seen[trimmed] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[trimmed] = true
|
||||||
|
list = append(list, trimmed)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
var keywordAliases = map[string][]string{
|
||||||
|
"lark": {"lark", "feishu", "larkshell", "larkhelper"},
|
||||||
|
"feishu": {"lark", "feishu", "larkshell", "larkhelper"},
|
||||||
|
"teams": {"teams", "ms-teams", "msteams"},
|
||||||
|
"zalo": {"zalo", "zalopcb"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandKeyword(kw string) []string {
|
||||||
|
base := []string{kw}
|
||||||
|
if aliases, ok := keywordAliases[kw]; ok {
|
||||||
|
base = append(base, aliases...)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, a := range base {
|
||||||
|
a = strings.TrimSpace(strings.ToLower(a))
|
||||||
|
if a != "" && !seen[a] {
|
||||||
|
seen[a] = true
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesAllowedKeyword(kw, pNameLower, wTitleLower string) bool {
|
||||||
|
for _, variant := range expandKeyword(kw) {
|
||||||
|
if strings.Contains(pNameLower, variant) || strings.Contains(wTitleLower, variant) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Blocker) SetKeywords(keywords string) {
|
func (b *Blocker) SetKeywords(keywords string) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
parts := strings.Split(keywords, ",")
|
b.allowedKeywords = parseKeywordList(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)
|
log.Printf("[BLOCKER] Keywords updated: %v", b.allowedKeywords)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +298,7 @@ func (b *Blocker) checkAndKill() {
|
|||||||
// 2. Kiểm tra xem có chứa bất kỳ từ khóa nào được cho phép không
|
// 2. Kiểm tra xem có chứa bất kỳ từ khóa nào được cho phép không
|
||||||
allowed := false
|
allowed := false
|
||||||
for _, kw := range keywords {
|
for _, kw := range keywords {
|
||||||
if strings.Contains(pNameLower, kw) || strings.Contains(wTitleLower, kw) {
|
if matchesAllowedKeyword(kw, pNameLower, wTitleLower) {
|
||||||
allowed = true
|
allowed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { ClassesTab } from './components/ClassesTab';
|
|||||||
import { StudentsTab } from './components/StudentsTab';
|
import { StudentsTab } from './components/StudentsTab';
|
||||||
import { LearningTab } from './components/LearningTab';
|
import { LearningTab } from './components/LearningTab';
|
||||||
import { NetworkTab } from './components/NetworkTab';
|
import { NetworkTab } from './components/NetworkTab';
|
||||||
|
import { AccountsTab } from './components/AccountsTab';
|
||||||
|
import { ChatWidget } from './components/ChatWidget';
|
||||||
import { ClassWorkspace } from './components/ClassWorkspace';
|
import { ClassWorkspace } from './components/ClassWorkspace';
|
||||||
import { NavHistoryBar, useRoute } from './components/NavHistoryBar';
|
import { NavHistoryBar, useRoute } from './components/NavHistoryBar';
|
||||||
import { goBack, navigate, parseRoute, pushNav, TAB_LABELS, type TabId } from './navigation';
|
import { goBack, navigate, parseRoute, pushNav, TAB_LABELS, type TabId } from './navigation';
|
||||||
|
import { useAuth } from './auth/AuthContext';
|
||||||
|
|
||||||
const IconDashboard = () => (
|
const IconDashboard = () => (
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -46,8 +49,16 @@ const IconNetwork = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const IconAccounts = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const { staff, logout } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initial = parseRoute();
|
const initial = parseRoute();
|
||||||
@@ -128,14 +139,27 @@ function App() {
|
|||||||
Quản lý mạng
|
Quản lý mạng
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<div className="nav-header">Hệ thống</div>
|
||||||
|
<li className="nav-item">
|
||||||
|
<button
|
||||||
|
className={`nav-btn ${route.tab === 'accounts' && !route.classId ? 'active' : ''}`}
|
||||||
|
onClick={() => navigate('accounts')}
|
||||||
|
>
|
||||||
|
<span className="nav-icon"><IconAccounts /></span>
|
||||||
|
Tài khoản
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 500 }}>Kết nối hệ thống</div>
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||||
<div className="token-badge" title="Token QLDT_TOKEN load từ server .env">
|
{staff?.email}
|
||||||
QLDT_TOKEN đã kết nối
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" className="link-btn" onClick={logout} style={{ marginTop: '0.35rem' }}>
|
||||||
|
Đăng xuất
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -157,10 +181,12 @@ function App() {
|
|||||||
{route.tab === 'students' && <StudentsTab />}
|
{route.tab === 'students' && <StudentsTab />}
|
||||||
{route.tab === 'learning' && <LearningTab />}
|
{route.tab === 'learning' && <LearningTab />}
|
||||||
{route.tab === 'network' && <NetworkTab />}
|
{route.tab === 'network' && <NetworkTab />}
|
||||||
|
{route.tab === 'accounts' && <AccountsTab />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<ChatWidget />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,161 @@
|
|||||||
const API_BASE = 'http://127.0.0.1:8080/api';
|
const API_BASE = 'http://127.0.0.1:8080/api';
|
||||||
|
|
||||||
|
function getToken(): string | null {
|
||||||
|
return localStorage.getItem('sc_staff_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function staffFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
const token = getToken();
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
if (init.body && !headers.has('Content-Type')) {
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
return fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseError(res: Response, fallback: string): Promise<never> {
|
||||||
|
const errData = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((errData as { error?: string }).error || fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaffUser {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiAuth = {
|
||||||
|
provision: async (email: string): Promise<{ ok: boolean; message: string }> => {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/provision`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Provision failed');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
login: async (email: string, password: string): Promise<{ token: string; staff: StaffUser; mustChangePassword: boolean }> => {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Đăng nhập thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
me: async (): Promise<{ staff: StaffUser }> => {
|
||||||
|
const res = await staffFetch('/auth/me');
|
||||||
|
if (!res.ok) await parseError(res, 'Unauthorized');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
changePassword: async (oldPassword: string, newPassword: string): Promise<{ ok: boolean; token: string; staff: StaffUser }> => {
|
||||||
|
const res = await staffFetch('/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ oldPassword, newPassword }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Đổi mật khẩu thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
forgotPassword: async (email: string): Promise<{ ok: boolean; message: string }> => {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/forgot-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Gửi mail thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
resetPassword: async (token: string, newPassword: string): Promise<{ ok: boolean; message: string }> => {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token, newPassword }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Đặt lại mật khẩu thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface EmailDomainItem {
|
||||||
|
id: number;
|
||||||
|
domain: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiAdmin = {
|
||||||
|
listEmailDomains: async (): Promise<{ data: EmailDomainItem[] }> => {
|
||||||
|
const res = await staffFetch('/admin/email-domains');
|
||||||
|
if (!res.ok) await parseError(res, 'Failed');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
addEmailDomain: async (domain: string) => {
|
||||||
|
const res = await staffFetch('/admin/email-domains', { method: 'POST', body: JSON.stringify({ domain }) });
|
||||||
|
if (!res.ok) await parseError(res, 'Failed');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
deleteEmailDomain: async (id: number) => {
|
||||||
|
const res = await staffFetch(`/admin/email-domains/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) await parseError(res, 'Failed');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ChatStudent {
|
||||||
|
studentRkId: number;
|
||||||
|
fullName: string;
|
||||||
|
studentCode: string;
|
||||||
|
email: string;
|
||||||
|
online: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: number;
|
||||||
|
staffId: number;
|
||||||
|
studentRkId: number;
|
||||||
|
senderRole: 'staff' | 'student';
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
staffName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatConversation {
|
||||||
|
studentRkId: number;
|
||||||
|
fullName: string;
|
||||||
|
studentCode: string;
|
||||||
|
lastMessage: string;
|
||||||
|
lastAt: string;
|
||||||
|
unread: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiChat = {
|
||||||
|
listConversations: async (): Promise<{ data: ChatConversation[] }> => {
|
||||||
|
const res = await staffFetch('/chat/conversations');
|
||||||
|
if (!res.ok) await parseError(res, 'Failed');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
searchStudents: async (q = ''): Promise<{ data: ChatStudent[] }> => {
|
||||||
|
const params = q ? `?q=${encodeURIComponent(q)}` : '';
|
||||||
|
const res = await staffFetch(`/chat/students${params}`);
|
||||||
|
if (!res.ok) await parseError(res, 'Failed');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
listMessages: async (studentRkId: number): Promise<{ data: ChatMessage[] }> => {
|
||||||
|
const res = await staffFetch(`/chat/messages/${studentRkId}`);
|
||||||
|
if (!res.ok) await parseError(res, 'Failed');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
sendMessage: async (studentRkId: number, body: string): Promise<{ ok: boolean; data: ChatMessage }> => {
|
||||||
|
const res = await staffFetch('/chat/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ studentRkId, body }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Failed');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export interface ClassItem {
|
export interface ClassItem {
|
||||||
rkId: number;
|
rkId: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -125,7 +281,7 @@ export interface StudentSessionLogItem {
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getStats: async (): Promise<StatsResponse> => {
|
getStats: async (): Promise<StatsResponse> => {
|
||||||
const res = await fetch(`${API_BASE}/stats`);
|
const res = await staffFetch('/stats');
|
||||||
if (!res.ok) throw new Error('Failed to fetch dashboard stats');
|
if (!res.ok) throw new Error('Failed to fetch dashboard stats');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
@@ -145,15 +301,14 @@ export const api = {
|
|||||||
if (params.systemRkId) query.set('systemRkId', String(params.systemRkId));
|
if (params.systemRkId) query.set('systemRkId', String(params.systemRkId));
|
||||||
if (params.studyingOnly) query.set('studyingOnly', 'true');
|
if (params.studyingOnly) query.set('studyingOnly', 'true');
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/classes?${query}`);
|
const res = await staffFetch(`/classes?${query}`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch classes');
|
if (!res.ok) throw new Error('Failed to fetch classes');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
updateClassStudying: async (rkId: number, isStudying: boolean): Promise<any> => {
|
updateClassStudying: async (rkId: number, isStudying: boolean): Promise<any> => {
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/studying`, {
|
const res = await staffFetch(`/classes/${rkId}/studying`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ isStudying }),
|
body: JSON.stringify({ isStudying }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -164,11 +319,17 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getClassStudents: async (rkId: number): Promise<{ data: StudentItem[]; total: number }> => {
|
getClassStudents: async (rkId: number): Promise<{ data: StudentItem[]; total: number }> => {
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/students`);
|
const res = await staffFetch(`/classes/${rkId}/students`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch class students roster');
|
if (!res.ok) throw new Error('Failed to fetch class students roster');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getClass: async (rkId: number): Promise<ClassItem> => {
|
||||||
|
const res = await staffFetch(`/classes/${rkId}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch class');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
getStudents: async (params: {
|
getStudents: async (params: {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
@@ -180,13 +341,13 @@ export const api = {
|
|||||||
});
|
});
|
||||||
if (params.q) query.set('q', params.q);
|
if (params.q) query.set('q', params.q);
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/students?${query}`);
|
const res = await staffFetch(`/students?${query}`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch students');
|
if (!res.ok) throw new Error('Failed to fetch students');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
startClassesSync: async (): Promise<{ ok: boolean; message: string }> => {
|
startClassesSync: async (): Promise<{ ok: boolean; message: string }> => {
|
||||||
const res = await fetch(`${API_BASE}/sync/classes/start`, { method: 'POST' });
|
const res = await staffFetch('/sync/classes/start', { method: 'POST' });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errData = await res.json().catch(() => ({}));
|
const errData = await res.json().catch(() => ({}));
|
||||||
throw new Error(errData.error || 'Failed to start classes sync');
|
throw new Error(errData.error || 'Failed to start classes sync');
|
||||||
@@ -195,13 +356,13 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getClassesSyncStatus: async (): Promise<SyncStatus> => {
|
getClassesSyncStatus: async (): Promise<SyncStatus> => {
|
||||||
const res = await fetch(`${API_BASE}/sync/classes/status`);
|
const res = await staffFetch('/sync/classes/status');
|
||||||
if (!res.ok) throw new Error('Failed to get classes sync status');
|
if (!res.ok) throw new Error('Failed to get classes sync status');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
startStudentsSync: async (): Promise<{ ok: boolean; message: string }> => {
|
startStudentsSync: async (): Promise<{ ok: boolean; message: string }> => {
|
||||||
const res = await fetch(`${API_BASE}/sync/students/start`, { method: 'POST' });
|
const res = await staffFetch('/sync/students/start', { method: 'POST' });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errData = await res.json().catch(() => ({}));
|
const errData = await res.json().catch(() => ({}));
|
||||||
throw new Error(errData.error || 'Failed to start students sync');
|
throw new Error(errData.error || 'Failed to start students sync');
|
||||||
@@ -210,7 +371,7 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getStudentsSyncStatus: async (): Promise<SyncStatus> => {
|
getStudentsSyncStatus: async (): Promise<SyncStatus> => {
|
||||||
const res = await fetch(`${API_BASE}/sync/students/status`);
|
const res = await staffFetch('/sync/students/status');
|
||||||
if (!res.ok) throw new Error('Failed to get students sync status');
|
if (!res.ok) throw new Error('Failed to get students sync status');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
@@ -218,21 +379,20 @@ export const api = {
|
|||||||
|
|
||||||
// Export individual learning functions to simplify imports in components
|
// Export individual learning functions to simplify imports in components
|
||||||
export const apiFetchActiveSchedules = async (): Promise<{ data: any[] }> => {
|
export const apiFetchActiveSchedules = async (): Promise<{ data: any[] }> => {
|
||||||
const res = await fetch(`${API_BASE}/classes/schedules`);
|
const res = await staffFetch('/classes/schedules');
|
||||||
if (!res.ok) throw new Error('Failed to fetch active schedules');
|
if (!res.ok) throw new Error('Failed to fetch active schedules');
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiFetchClassSchedule = async (rkId: number): Promise<{ data: ClassScheduleItem[] }> => {
|
export const apiFetchClassSchedule = async (rkId: number): Promise<{ data: ClassScheduleItem[] }> => {
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`);
|
const res = await staffFetch(`/classes/${rkId}/schedule`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch class schedule');
|
if (!res.ok) throw new Error('Failed to fetch class schedule');
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiSaveClassSchedule = async (rkId: number, schedules: ClassScheduleItem[]): Promise<any> => {
|
export const apiSaveClassSchedule = async (rkId: number, schedules: ClassScheduleItem[]): Promise<any> => {
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`, {
|
const res = await staffFetch(`/classes/${rkId}/schedule`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ schedules }),
|
body: JSON.stringify({ schedules }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to save class schedule');
|
if (!res.ok) throw new Error('Failed to save class schedule');
|
||||||
@@ -240,21 +400,20 @@ export const apiSaveClassSchedule = async (rkId: number, schedules: ClassSchedul
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const apiDeleteClassSchedule = async (rkId: number): Promise<any> => {
|
export const apiDeleteClassSchedule = async (rkId: number): Promise<any> => {
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`, { method: 'DELETE' });
|
const res = await staffFetch(`/classes/${rkId}/schedule`, { method: 'DELETE' });
|
||||||
if (!res.ok) throw new Error('Failed to delete class schedule');
|
if (!res.ok) throw new Error('Failed to delete class schedule');
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiFetchAllowedApps = async (rkId: number): Promise<{ classRkId: number; keywords: string }> => {
|
export const apiFetchAllowedApps = async (rkId: number): Promise<{ classRkId: number; keywords: string }> => {
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/allowed-apps`);
|
const res = await staffFetch(`/classes/${rkId}/allowed-apps`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch allowed apps keywords');
|
if (!res.ok) throw new Error('Failed to fetch allowed apps keywords');
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiSaveAllowedApps = async (rkId: number, keywords: string): Promise<any> => {
|
export const apiSaveAllowedApps = async (rkId: number, keywords: string): Promise<any> => {
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/allowed-apps`, {
|
const res = await staffFetch(`/classes/${rkId}/allowed-apps`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ keywords }),
|
body: JSON.stringify({ keywords }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to save allowed apps keywords');
|
if (!res.ok) throw new Error('Failed to save allowed apps keywords');
|
||||||
@@ -277,7 +436,7 @@ export const apiFetchAppPool = async (q = '', limit = 50): Promise<{ data: AppPo
|
|||||||
if (q.trim()) params.set('q', q.trim());
|
if (q.trim()) params.set('q', q.trim());
|
||||||
params.set('limit', String(limit));
|
params.set('limit', String(limit));
|
||||||
const query = params.toString() ? `?${params.toString()}` : '';
|
const query = params.toString() ? `?${params.toString()}` : '';
|
||||||
const res = await fetch(`${API_BASE}/app-pool${query}`);
|
const res = await staffFetch(`/app-pool${query}`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch app pool');
|
if (!res.ok) throw new Error('Failed to fetch app pool');
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
@@ -305,21 +464,20 @@ export const apiFetchWifiPool = async (q = '', limit = 50): Promise<{ data: Wifi
|
|||||||
if (q.trim()) params.set('q', q.trim());
|
if (q.trim()) params.set('q', q.trim());
|
||||||
params.set('limit', String(limit));
|
params.set('limit', String(limit));
|
||||||
const query = params.toString() ? `?${params.toString()}` : '';
|
const query = params.toString() ? `?${params.toString()}` : '';
|
||||||
const res = await fetch(`${API_BASE}/wifi-pool${query}`);
|
const res = await staffFetch(`/wifi-pool${query}`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch wifi pool');
|
if (!res.ok) throw new Error('Failed to fetch wifi pool');
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiFetchAcceptedWifis = async (): Promise<{ data: AcceptedWifiItem[] }> => {
|
export const apiFetchAcceptedWifis = async (): Promise<{ data: AcceptedWifiItem[] }> => {
|
||||||
const res = await fetch(`${API_BASE}/network/accepted-wifis`);
|
const res = await staffFetch('/network/accepted-wifis');
|
||||||
if (!res.ok) throw new Error('Failed to fetch accepted wifis');
|
if (!res.ok) throw new Error('Failed to fetch accepted wifis');
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiSaveAcceptedWifis = async (items: WifiAcceptItem[]): Promise<any> => {
|
export const apiSaveAcceptedWifis = async (items: WifiAcceptItem[]): Promise<any> => {
|
||||||
const res = await fetch(`${API_BASE}/network/accepted-wifis`, {
|
const res = await staffFetch('/network/accepted-wifis', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ items }),
|
body: JSON.stringify({ items }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to save accepted wifis');
|
if (!res.ok) throw new Error('Failed to save accepted wifis');
|
||||||
@@ -338,13 +496,13 @@ export const apiFetchClassSessionLogs = async (
|
|||||||
if (date) params.set('date', date);
|
if (date) params.set('date', date);
|
||||||
if (period) params.set('period', String(period));
|
if (period) params.set('period', String(period));
|
||||||
const query = params.toString() ? `?${params.toString()}` : '';
|
const query = params.toString() ? `?${params.toString()}` : '';
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/session-logs${query}`);
|
const res = await staffFetch(`/classes/${rkId}/session-logs${query}`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch class session logs');
|
if (!res.ok) throw new Error('Failed to fetch class session logs');
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => {
|
export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => {
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/online-students`);
|
const res = await staffFetch(`/classes/${rkId}/online-students`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch online students list');
|
if (!res.ok) throw new Error('Failed to fetch online students list');
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
@@ -356,7 +514,7 @@ export const apiFetchClassCourses = async (rkId: number): Promise<{
|
|||||||
warning?: string;
|
warning?: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
}> => {
|
}> => {
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/courses`);
|
const res = await staffFetch(`/classes/${rkId}/courses`);
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
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');
|
throw new Error(body.error || body.hint || 'Không tải được danh sách môn học từ QLĐT');
|
||||||
@@ -365,20 +523,20 @@ export const apiFetchClassCourses = async (rkId: number): Promise<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const apiApplyScheduleTemplate = async (rkId: number): Promise<{ created: number; skipped: number }> => {
|
export const apiApplyScheduleTemplate = async (rkId: number): Promise<{ created: number; skipped: number }> => {
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/schedule/apply-template`, { method: 'POST' });
|
const res = await staffFetch(`/classes/${rkId}/schedule/apply-template`, { method: 'POST' });
|
||||||
if (!res.ok) throw new Error('Failed to apply schedule template');
|
if (!res.ok) throw new Error('Failed to apply schedule template');
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiFetchAttendanceShifts = async (rkId: number, date?: string) => {
|
export const apiFetchAttendanceShifts = async (rkId: number, date?: string) => {
|
||||||
const q = date ? `?date=${date}` : '';
|
const q = date ? `?date=${date}` : '';
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/shifts${q}`);
|
const res = await staffFetch(`/classes/${rkId}/attendance/shifts${q}`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch attendance shifts');
|
if (!res.ok) throw new Error('Failed to fetch attendance shifts');
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiFetchAttendance = async (rkId: number, date: string, period: number) => {
|
export const apiFetchAttendance = async (rkId: number, date: string, period: number) => {
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/attendance?date=${date}&period=${period}`);
|
const res = await staffFetch(`/classes/${rkId}/attendance?date=${date}&period=${period}`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch attendance');
|
if (!res.ok) throw new Error('Failed to fetch attendance');
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
@@ -387,9 +545,8 @@ export const apiUpdateAttendanceStatus = async (
|
|||||||
rkId: number,
|
rkId: number,
|
||||||
payload: { date: string; period: number; studentRkId: number; status: number }
|
payload: { date: string; period: number; studentRkId: number; status: number }
|
||||||
) => {
|
) => {
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/status`, {
|
const res = await staffFetch(`/classes/${rkId}/attendance/status`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -400,9 +557,8 @@ export const apiUpdateAttendanceStatus = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => {
|
export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => {
|
||||||
const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/push-qldt`, {
|
const res = await staffFetch(`/classes/${rkId}/attendance/push-qldt`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ date, period }),
|
body: JSON.stringify({ date, period }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
114
management/src/auth/AuthContext.tsx
Normal file
114
management/src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import { apiAuth } from '../api';
|
||||||
|
|
||||||
|
export interface StaffUser {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null;
|
||||||
|
staff: StaffUser | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
provision: (email: string) => Promise<string>;
|
||||||
|
logout: () => void;
|
||||||
|
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
|
||||||
|
forgotPassword: (email: string) => Promise<string>;
|
||||||
|
resetPassword: (token: string, newPassword: string) => Promise<void>;
|
||||||
|
refreshMe: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'sc_staff_token';
|
||||||
|
const AuthContext = createContext<AuthState | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
||||||
|
const [staff, setStaff] = useState<StaffUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const applySession = useCallback((nextToken: string | null, nextStaff: StaffUser | null) => {
|
||||||
|
setToken(nextToken);
|
||||||
|
setStaff(nextStaff);
|
||||||
|
if (nextToken) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, nextToken);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshMe = useCallback(async () => {
|
||||||
|
if (!token) {
|
||||||
|
setStaff(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await apiAuth.me();
|
||||||
|
setStaff(res.staff);
|
||||||
|
} catch {
|
||||||
|
applySession(null, null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, applySession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshMe();
|
||||||
|
}, [refreshMe]);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const res = await apiAuth.login(email, password);
|
||||||
|
applySession(res.token, res.staff);
|
||||||
|
}, [applySession]);
|
||||||
|
|
||||||
|
const provision = useCallback(async (email: string) => {
|
||||||
|
const res = await apiAuth.provision(email);
|
||||||
|
return res.message;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
applySession(null, null);
|
||||||
|
}, [applySession]);
|
||||||
|
|
||||||
|
const changePassword = useCallback(async (oldPassword: string, newPassword: string) => {
|
||||||
|
const res = await apiAuth.changePassword(oldPassword, newPassword);
|
||||||
|
applySession(res.token, res.staff);
|
||||||
|
}, [applySession]);
|
||||||
|
|
||||||
|
const forgotPassword = useCallback(async (email: string) => {
|
||||||
|
const res = await apiAuth.forgotPassword(email);
|
||||||
|
return res.message;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetPassword = useCallback(async (resetToken: string, newPassword: string) => {
|
||||||
|
await apiAuth.resetPassword(resetToken, newPassword);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<AuthState>(() => ({
|
||||||
|
token,
|
||||||
|
staff,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
provision,
|
||||||
|
logout,
|
||||||
|
changePassword,
|
||||||
|
forgotPassword,
|
||||||
|
resetPassword,
|
||||||
|
refreshMe,
|
||||||
|
}), [token, staff, loading, login, provision, logout, changePassword, forgotPassword, resetPassword, refreshMe]);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
167
management/src/components/AccountsTab.tsx
Normal file
167
management/src/components/AccountsTab.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { apiAdmin, type EmailDomainItem } from '../api';
|
||||||
|
|
||||||
|
export function ChangePasswordPage({ forced }: { forced?: boolean }) {
|
||||||
|
const { changePassword, logout } = useAuth();
|
||||||
|
const [oldPassword, setOldPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setError('Mật khẩu mới tối thiểu 8 ký tự');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirm) {
|
||||||
|
setError('Xác nhận mật khẩu không khớp');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await changePassword(oldPassword, newPassword);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || 'Đổi mật khẩu thất bại');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-screen">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1 className="login-heading">{forced ? 'Đổi mật khẩu bắt buộc' : 'Đổi mật khẩu'}</h1>
|
||||||
|
{forced && <p className="login-hint">Lần đăng nhập đầu tiên — vui lòng đặt mật khẩu mới trước khi tiếp tục.</p>}
|
||||||
|
<form onSubmit={submit} className="login-form">
|
||||||
|
<label className="login-field">
|
||||||
|
<span>Mật khẩu hiện tại</span>
|
||||||
|
<input type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label className="login-field">
|
||||||
|
<span>Mật khẩu mới</span>
|
||||||
|
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required minLength={8} />
|
||||||
|
</label>
|
||||||
|
<label className="login-field">
|
||||||
|
<span>Xác nhận mật khẩu mới</span>
|
||||||
|
<input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required minLength={8} />
|
||||||
|
</label>
|
||||||
|
{error && <div className="login-error">{error}</div>}
|
||||||
|
<button type="submit" className="btn btn-primary login-submit" disabled={busy}>
|
||||||
|
{busy ? 'Đang lưu...' : 'Lưu mật khẩu'}
|
||||||
|
</button>
|
||||||
|
{!forced && (
|
||||||
|
<button type="button" className="link-btn" style={{ marginTop: '0.5rem' }} onClick={logout}>
|
||||||
|
Đăng xuất
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountsTab() {
|
||||||
|
const { staff, changePassword } = useAuth();
|
||||||
|
const [domains, setDomains] = useState<EmailDomainItem[]>([]);
|
||||||
|
const [newDomain, setNewDomain] = useState('');
|
||||||
|
const [oldPw, setOldPw] = useState('');
|
||||||
|
const [newPw, setNewPw] = useState('');
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const res = await apiAdmin.listEmailDomains();
|
||||||
|
setDomains(res.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load().catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addDomain = async () => {
|
||||||
|
if (!newDomain.trim()) return;
|
||||||
|
await apiAdmin.addEmailDomain(newDomain.trim());
|
||||||
|
setNewDomain('');
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDomain = async (id: number) => {
|
||||||
|
if (!confirm('Xóa đuôi email này?')) return;
|
||||||
|
await apiAdmin.deleteEmailDomain(id);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPw = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErr('');
|
||||||
|
setMsg('');
|
||||||
|
try {
|
||||||
|
await changePassword(oldPw, newPw);
|
||||||
|
setMsg('Đã đổi mật khẩu');
|
||||||
|
setOldPw('');
|
||||||
|
setNewPw('');
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || 'Lỗi');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<header className="page-header">
|
||||||
|
<h1 className="page-title">Tài khoản & bảo mật</h1>
|
||||||
|
<p className="page-desc">Quản lý đuôi email được phép và đổi mật khẩu.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: '1.25rem' }}>
|
||||||
|
<h2 className="section-title">Tài khoản hiện tại</h2>
|
||||||
|
<p style={{ margin: 0, color: 'var(--text-muted)' }}>{staff?.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: '1.25rem' }}>
|
||||||
|
<h2 className="section-title">Đuôi email được phép</h2>
|
||||||
|
<p className="page-desc" style={{ marginTop: 0 }}>
|
||||||
|
Nếu chưa có bản ghi nào, hệ thống chấp nhận mọi đuôi email. Thêm đuôi để giới hạn truy cập.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||||
|
<input
|
||||||
|
placeholder="vd: rikkeiacademy.com"
|
||||||
|
value={newDomain}
|
||||||
|
onChange={(e) => setNewDomain(e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={addDomain}>Thêm</button>
|
||||||
|
</div>
|
||||||
|
<ul className="domain-list">
|
||||||
|
{domains.length === 0 && <li className="domain-empty">Chưa cấu hình — chấp nhận tất cả đuôi email</li>}
|
||||||
|
{domains.map((d) => (
|
||||||
|
<li key={d.id} className="domain-item">
|
||||||
|
<span>@{d.domain}</span>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => removeDomain(d.id)}>Xóa</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: '1.25rem' }}>
|
||||||
|
<h2 className="section-title">Đổi mật khẩu</h2>
|
||||||
|
<form onSubmit={submitPw} className="login-form" style={{ maxWidth: 400 }}>
|
||||||
|
<label className="login-field">
|
||||||
|
<span>Mật khẩu hiện tại</span>
|
||||||
|
<input type="password" value={oldPw} onChange={(e) => setOldPw(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label className="login-field">
|
||||||
|
<span>Mật khẩu mới</span>
|
||||||
|
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} required minLength={8} />
|
||||||
|
</label>
|
||||||
|
{err && <div className="login-error">{err}</div>}
|
||||||
|
{msg && <div className="login-success">{msg}</div>}
|
||||||
|
<button type="submit" className="btn btn-primary">Lưu</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
management/src/components/ChatWidget.tsx
Normal file
231
management/src/components/ChatWidget.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
|
export function ChatWidget() {
|
||||||
|
const { staff } = useAuth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [students, setStudents] = useState<ChatStudent[]>([]);
|
||||||
|
const [conversations, setConversations] = useState<ChatConversation[]>([]);
|
||||||
|
const [active, setActive] = useState<ChatStudent | null>(null);
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const activeRef = useRef<ChatStudent | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeRef.current = active;
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
const scrollBottom = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalUnread = conversations.reduce((n, c) => n + (c.unread || 0), 0);
|
||||||
|
|
||||||
|
const loadConversations = useCallback(async () => {
|
||||||
|
const res = await apiChat.listConversations();
|
||||||
|
setConversations(res.data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMessages = useCallback(async (studentRkId: number) => {
|
||||||
|
const res = await apiChat.listMessages(studentRkId);
|
||||||
|
setMessages(res.data);
|
||||||
|
scrollBottom();
|
||||||
|
await loadConversations();
|
||||||
|
}, [loadConversations]);
|
||||||
|
|
||||||
|
const searchStudents = useCallback(async (q: string) => {
|
||||||
|
const res = await apiChat.searchStudents(q);
|
||||||
|
setStudents(res.data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pickStudent = async (s: ChatStudent) => {
|
||||||
|
setActive(s);
|
||||||
|
setPickerOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
await loadMessages(s.studentRkId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickFromConversation = async (c: ChatConversation) => {
|
||||||
|
await pickStudent({
|
||||||
|
studentRkId: c.studentRkId,
|
||||||
|
fullName: c.fullName,
|
||||||
|
studentCode: c.studentCode,
|
||||||
|
email: '',
|
||||||
|
online: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !staff?.id) return;
|
||||||
|
loadConversations().catch(console.error);
|
||||||
|
searchStudents('').catch(console.error);
|
||||||
|
}, [open, staff?.id, loadConversations, searchStudents]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!staff?.id) return;
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.hostname}:8080/ws?role=teacher&staffId=${staff.id}`;
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(ev.data);
|
||||||
|
if (payload.event !== 'chat:message') return;
|
||||||
|
const msg = payload.data as ChatMessage;
|
||||||
|
loadConversations().catch(console.error);
|
||||||
|
const current = activeRef.current;
|
||||||
|
if (current && msg.studentRkId === current.studentRkId) {
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||||
|
return [...prev, msg];
|
||||||
|
});
|
||||||
|
scrollBottom();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => ws.close();
|
||||||
|
}, [staff?.id, loadConversations]);
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (!active || !draft.trim()) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const res = await apiChat.sendMessage(active.studentRkId, draft.trim());
|
||||||
|
setMessages((prev) => [...prev, res.data]);
|
||||||
|
setDraft('');
|
||||||
|
scrollBottom();
|
||||||
|
await loadConversations();
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!staff) return null;
|
||||||
|
|
||||||
|
const dock = (
|
||||||
|
<div className="chat-dock">
|
||||||
|
{!open ? (
|
||||||
|
<button type="button" className="chat-fab" onClick={() => setOpen(true)} title="Tin nhắn">
|
||||||
|
💬
|
||||||
|
{totalUnread > 0 && (
|
||||||
|
<span className="chat-fab-badge">{totalUnread > 9 ? '9+' : totalUnread}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="chat-messenger">
|
||||||
|
<header className="chat-messenger-head">
|
||||||
|
<strong>Tin nhắn</strong>
|
||||||
|
<div className="chat-head-actions">
|
||||||
|
<button type="button" className="chat-icon-btn" onClick={() => setPickerOpen((v) => !v)} title="Tin mới">
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button type="button" className="chat-icon-btn" onClick={() => { setOpen(false); setActive(null); setPickerOpen(false); }} title="Đóng">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="chat-messenger-body">
|
||||||
|
<aside className="chat-sidebar">
|
||||||
|
<div className="chat-sidebar-search">
|
||||||
|
<input
|
||||||
|
placeholder="Tìm sinh viên..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
searchStudents(e.target.value).catch(console.error);
|
||||||
|
}}
|
||||||
|
onFocus={() => setPickerOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{pickerOpen && (
|
||||||
|
<ul className="chat-picker-list">
|
||||||
|
{students.map((s) => (
|
||||||
|
<li key={s.studentRkId}>
|
||||||
|
<button type="button" onClick={() => pickStudent(s)}>
|
||||||
|
<span className="chat-conv-name">{s.fullName}</span>
|
||||||
|
<span className="chat-conv-meta">{s.studentCode}{s.online ? ' · online' : ''}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{students.length === 0 && <li className="chat-empty-hint">Không tìm thấy sinh viên</li>}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<ul className="chat-conv-list">
|
||||||
|
{conversations.map((c) => (
|
||||||
|
<li key={c.studentRkId}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={active?.studentRkId === c.studentRkId ? 'active' : ''}
|
||||||
|
onClick={() => pickFromConversation(c)}
|
||||||
|
>
|
||||||
|
<div className="chat-conv-row">
|
||||||
|
<span className="chat-conv-name">{c.fullName}</span>
|
||||||
|
{c.unread > 0 && <span className="chat-conv-unread">{c.unread}</span>}
|
||||||
|
</div>
|
||||||
|
<span className="chat-conv-preview">{c.lastMessage || '—'}</span>
|
||||||
|
<span className="chat-conv-meta">{c.studentCode}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{conversations.length === 0 && !pickerOpen && (
|
||||||
|
<li className="chat-empty-hint">Chưa có hội thoại — bấm ✏️ để nhắn sinh viên</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="chat-thread">
|
||||||
|
{!active ? (
|
||||||
|
<div className="chat-thread-empty">
|
||||||
|
<p>Chọn hội thoại bên trái hoặc tìm sinh viên để bắt đầu chat</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="chat-thread-head">
|
||||||
|
<div>
|
||||||
|
<strong>{active.fullName}</strong>
|
||||||
|
<div className="chat-sub">{active.studentCode}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-messages" ref={listRef}>
|
||||||
|
{messages.map((m) => (
|
||||||
|
<div key={m.id} className={`chat-bubble chat-bubble--${m.senderRole}`}>
|
||||||
|
<div className="chat-bubble-body">{m.body}</div>
|
||||||
|
<div className="chat-bubble-time">
|
||||||
|
{new Date(m.createdAt).toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{messages.length === 0 && <div className="chat-empty-hint">Chưa có tin nhắn</div>}
|
||||||
|
</div>
|
||||||
|
<div className="chat-compose">
|
||||||
|
<input
|
||||||
|
placeholder="Nhập tin nhắn..."
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" disabled={sending || !draft.trim()} onClick={send}>
|
||||||
|
Gửi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(dock, document.body);
|
||||||
|
}
|
||||||
@@ -58,9 +58,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// Fetch specific class detail directly
|
// Fetch specific class detail directly
|
||||||
const targetClassRes = await fetch(`http://127.0.0.1:8080/api/classes/${classId}`);
|
const classData = await api.getClass(classId);
|
||||||
if (targetClassRes.ok) {
|
|
||||||
const classData = await targetClassRes.json();
|
|
||||||
setClassInfo(classData);
|
setClassInfo(classData);
|
||||||
pushNav({
|
pushNav({
|
||||||
kind: 'class',
|
kind: 'class',
|
||||||
@@ -68,7 +66,6 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
classId,
|
classId,
|
||||||
label: classData.name || `Lớp ${classId}`,
|
label: classData.name || `Lớp ${classId}`,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch students roster
|
// Fetch students roster
|
||||||
const studentsRes = await apiFetchClassStudents(classId);
|
const studentsRes = await apiFetchClassStudents(classId);
|
||||||
|
|||||||
114
management/src/components/LoginPage.tsx
Normal file
114
management/src/components/LoginPage.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
|
type Mode = 'login' | 'provision' | 'forgot' | 'reset';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login, provision, forgotPassword, resetPassword } = useAuth();
|
||||||
|
const [mode, setMode] = useState<Mode>('login');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [resetToken, setResetToken] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setMessage('');
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (mode === 'login') {
|
||||||
|
await login(email, password);
|
||||||
|
} else if (mode === 'provision') {
|
||||||
|
const msg = await provision(email);
|
||||||
|
setMessage(msg);
|
||||||
|
setMode('login');
|
||||||
|
} else if (mode === 'forgot') {
|
||||||
|
const msg = await forgotPassword(email);
|
||||||
|
setMessage(msg);
|
||||||
|
setMode('reset');
|
||||||
|
} else if (mode === 'reset') {
|
||||||
|
await resetPassword(resetToken, newPassword);
|
||||||
|
setMessage('Đã đặt lại mật khẩu. Vui lòng đăng nhập.');
|
||||||
|
setMode('login');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || 'Có lỗi xảy ra');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-screen">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-brand">
|
||||||
|
<img src="/logo.jpeg" alt="Simple Care" className="brand-logo-img" />
|
||||||
|
<div>
|
||||||
|
<div className="login-brand-title">Simple Care</div>
|
||||||
|
<div className="login-brand-sub">Quản lý giám sát — Rikkei Education</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="login-heading">
|
||||||
|
{mode === 'login' && 'Đăng nhập'}
|
||||||
|
{mode === 'provision' && 'Truy cập lần đầu'}
|
||||||
|
{mode === 'forgot' && 'Quên mật khẩu'}
|
||||||
|
{mode === 'reset' && 'Đặt lại mật khẩu'}
|
||||||
|
</h1>
|
||||||
|
<p className="login-hint">
|
||||||
|
{mode === 'provision'
|
||||||
|
? 'Nhập email tổ chức. Hệ thống gửi mật khẩu tạm nếu đuôi email được phép.'
|
||||||
|
: 'Chỉ email có đuôi tổ chức được phép truy cập.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="login-form">
|
||||||
|
{(mode === 'login' || mode === 'provision' || mode === 'forgot') && (
|
||||||
|
<label className="login-field">
|
||||||
|
<span>Email</span>
|
||||||
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoComplete="email" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{mode === 'login' && (
|
||||||
|
<label className="login-field">
|
||||||
|
<span>Mật khẩu</span>
|
||||||
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required autoComplete="current-password" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{mode === 'reset' && (
|
||||||
|
<>
|
||||||
|
<label className="login-field">
|
||||||
|
<span>Mã từ email</span>
|
||||||
|
<input value={resetToken} onChange={(e) => setResetToken(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label className="login-field">
|
||||||
|
<span>Mật khẩu mới</span>
|
||||||
|
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required minLength={8} />
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && <div className="login-error">{error}</div>}
|
||||||
|
{message && <div className="login-success">{message}</div>}
|
||||||
|
<button type="submit" className="btn btn-primary login-submit" disabled={busy}>
|
||||||
|
{busy ? 'Đang xử lý...' : mode === 'login' ? 'Đăng nhập' : mode === 'provision' ? 'Gửi mật khẩu' : mode === 'forgot' ? 'Gửi mã' : 'Đặt lại mật khẩu'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="login-links">
|
||||||
|
{mode === 'login' && (
|
||||||
|
<>
|
||||||
|
<button type="button" className="link-btn" onClick={() => setMode('provision')}>Truy cập lần đầu</button>
|
||||||
|
<button type="button" className="link-btn" onClick={() => setMode('forgot')}>Quên mật khẩu?</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{mode !== 'login' && (
|
||||||
|
<button type="button" className="link-btn" onClick={() => setMode('login')}>← Quay lại đăng nhập</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3233,3 +3233,427 @@ input:checked + .slider:before {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Login ── */
|
||||||
|
.auth-shell {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-screen {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 1.75rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand-title {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand-sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-heading {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hint {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field input {
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-submit {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-success {
|
||||||
|
color: #22c55e;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chat messenger dock ── */
|
||||||
|
.chat-dock {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
z-index: 10000;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-fab {
|
||||||
|
position: relative;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-fab-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messenger {
|
||||||
|
width: min(720px, calc(100vw - 40px));
|
||||||
|
height: min(520px, calc(100vh - 100px));
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messenger-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messenger-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar {
|
||||||
|
width: 260px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar-search {
|
||||||
|
padding: 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar-search input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-picker-list,
|
||||||
|
.chat-conv-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-picker-list {
|
||||||
|
max-height: 160px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-conv-list {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-picker-list button,
|
||||||
|
.chat-conv-list button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-conv-list button:hover,
|
||||||
|
.chat-picker-list button:hover {
|
||||||
|
background: #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-conv-list button.active {
|
||||||
|
background: var(--accent-light);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-conv-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-conv-name {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-conv-preview {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-conv-meta {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-conv-unread {
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-empty-hint {
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-thread {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-thread-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-thread-head {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sub {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-head-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-icon-btn:hover {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble {
|
||||||
|
max-width: 78%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble--staff {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble--student {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble-body {
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble--staff .chat-bubble-body {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble--student .chat-bubble-body {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble-time {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-compose {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
background: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-compose input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,40 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { AuthProvider, useAuth } from './auth/AuthContext'
|
||||||
|
import { LoginPage } from './components/LoginPage'
|
||||||
|
import { ChangePasswordPage } from './components/AccountsTab'
|
||||||
|
|
||||||
|
function AppGate() {
|
||||||
|
const { token, staff, loading } = useAuth();
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="auth-shell">
|
||||||
|
<div className="login-card card">Đang tải...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="auth-shell">
|
||||||
|
<LoginPage />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (staff?.mustChangePassword) {
|
||||||
|
return (
|
||||||
|
<div className="auth-shell">
|
||||||
|
<ChangePasswordPage forced />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <App />;
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<AppGate />
|
||||||
|
</AuthProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'network';
|
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'network' | 'accounts';
|
||||||
|
|
||||||
export interface NavEntry {
|
export interface NavEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,13 +15,14 @@ export const TAB_LABELS: Record<TabId, string> = {
|
|||||||
students: 'Sinh viên',
|
students: 'Sinh viên',
|
||||||
learning: 'Giám sát & Lịch học',
|
learning: 'Giám sát & Lịch học',
|
||||||
network: 'Quản lý mạng',
|
network: 'Quản lý mạng',
|
||||||
|
accounts: 'Tài khoản',
|
||||||
};
|
};
|
||||||
|
|
||||||
const HISTORY_KEY = 'sc_nav_history';
|
const HISTORY_KEY = 'sc_nav_history';
|
||||||
const MAX_HISTORY = 10;
|
const MAX_HISTORY = 10;
|
||||||
|
|
||||||
function isTabId(value: string | null): value is TabId {
|
function isTabId(value: string | null): value is TabId {
|
||||||
return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'network';
|
return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'network' || value === 'accounts';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseRoute(): { tab: TabId; classId: number | null } {
|
export function parseRoute(): { tab: TabId; classId: number | null } {
|
||||||
|
|||||||
@@ -1 +1,9 @@
|
|||||||
QLDT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBodW9jbnRiQHJpa2tlaWFjYWRlbXkuY29tIiwibmFtZSI6Ik5ndXnhu4VuIFRoYW5oIELDrG5oIFBoxrDhu5tjIiwiaWQiOjI0LCJyb2xlIjpbeyJpZCI6MSwibmFtZSI6IkFETUlOIn0seyJpZCI6MywibmFtZSI6IlRFQUNIRVIifV0sInR5cGUiOiJ1c2VyIiwiaWF0IjoxNzgyNzc3NzEzLCJleHAiOjE3ODI4NjQxMTN9.bj4nMPYZWVIsuiuA3XHLtc8yFFwAC9MDlPx-FnIM6z4
|
QLDT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBodW9jbnRiQHJpa2tlaWFjYWRlbXkuY29tIiwibmFtZSI6Ik5ndXnhu4VuIFRoYW5oIELDrG5oIFBoxrDhu5tjIiwiaWQiOjI0LCJyb2xlIjpbeyJpZCI6MSwibmFtZSI6IkFETUlOIn0seyJpZCI6MywibmFtZSI6IlRFQUNIRVIifV0sInR5cGUiOiJ1c2VyIiwiaWF0IjoxNzgyNzc3NzEzLCJleHAiOjE3ODI4NjQxMTN9.bj4nMPYZWVIsuiuA3XHLtc8yFFwAC9MDlPx-FnIM6z4
|
||||||
|
|
||||||
|
JWT_SECRET=simple-care-staff-jwt-change-in-production
|
||||||
|
|
||||||
|
MAIL_HOST=smtp.gmail.com
|
||||||
|
MAIL_PORT=465
|
||||||
|
MAIL_SECURE=true
|
||||||
|
MAIL_AUTH_USER=phuocnguyenbp0@gmail.com
|
||||||
|
MAIL_AUTH_PASS="cygi rtnv kkbw uuoz"
|
||||||
@@ -9,6 +9,7 @@ require (
|
|||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
github.com/gofiber/fiber/v2 v2.52.13 // indirect
|
github.com/gofiber/fiber/v2 v2.52.13 // indirect
|
||||||
github.com/gofiber/websocket/v2 v2.2.1 // indirect
|
github.com/gofiber/websocket/v2 v2.2.1 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
@@ -22,8 +23,9 @@ require (
|
|||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
golang.org/x/crypto v0.53.0 // indirect
|
||||||
golang.org/x/text v0.20.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
golang.org/x/text v0.38.0 // indirect
|
||||||
gorm.io/driver/mysql v1.6.0 // indirect
|
gorm.io/driver/mysql v1.6.0 // indirect
|
||||||
gorm.io/gorm v1.31.2 // indirect
|
gorm.io/gorm v1.31.2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJb
|
|||||||
github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
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 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
|
||||||
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
|
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
@@ -37,12 +39,18 @@ github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1S
|
|||||||
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||||
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||||
|
|||||||
105
server/internal/auth/auth.go
Normal file
105
server/internal/auth/auth.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"server/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const staffCtxKey = "staffId"
|
||||||
|
|
||||||
|
type StaffClaims struct {
|
||||||
|
StaffID uint `json:"staffId"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func JWTSecret() string {
|
||||||
|
if s := os.Getenv("JWT_SECRET"); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "simple-care-staff-jwt-change-me-in-production"
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
return string(b), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckPassword(hash, password string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RandomPassword(n int) string {
|
||||||
|
if n < 8 {
|
||||||
|
n = 8
|
||||||
|
}
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
func RandomToken(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func IssueToken(staff *models.StaffAccount) (string, error) {
|
||||||
|
claims := StaffClaims{
|
||||||
|
StaffID: staff.ID,
|
||||||
|
Email: staff.Email,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return t.SignedString([]byte(JWTSecret()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseToken(tokenStr string) (*StaffClaims, error) {
|
||||||
|
t, err := jwt.ParseWithClaims(tokenStr, &StaffClaims{}, func(t *jwt.Token) (any, error) {
|
||||||
|
return []byte(JWTSecret()), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
claims, ok := t.Claims.(*StaffClaims)
|
||||||
|
if !ok || !t.Valid {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EmailDomainPart(email string) string {
|
||||||
|
parts := strings.Split(strings.ToLower(strings.TrimSpace(email)), "@")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsEmailDomainAllowed(db *gorm.DB, email string) bool {
|
||||||
|
domain := EmailDomainPart(email)
|
||||||
|
if domain == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
db.Model(&models.EmailDomain{}).Where("is_active = ?", true).Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var found int64
|
||||||
|
db.Model(&models.EmailDomain{}).Where("domain = ? AND is_active = ?", domain, true).Count(&found)
|
||||||
|
return found > 0
|
||||||
|
}
|
||||||
@@ -53,6 +53,10 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.AcceptedWifi{},
|
&models.AcceptedWifi{},
|
||||||
&models.StudentSession{},
|
&models.StudentSession{},
|
||||||
&models.AttendanceResult{},
|
&models.AttendanceResult{},
|
||||||
|
&models.StaffAccount{},
|
||||||
|
&models.EmailDomain{},
|
||||||
|
&models.PasswordResetToken{},
|
||||||
|
&models.ChatMessage{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
289
server/internal/handlers/handlers_auth.go
Normal file
289
server/internal/handlers/handlers_auth.go
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/internal/auth"
|
||||||
|
"server/internal/mail"
|
||||||
|
"server/internal/models"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeEmail(email string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(email))
|
||||||
|
}
|
||||||
|
|
||||||
|
func staffPublic(a models.StaffAccount) fiber.Map {
|
||||||
|
return fiber.Map{
|
||||||
|
"id": a.ID,
|
||||||
|
"email": a.Email,
|
||||||
|
"fullName": a.FullName,
|
||||||
|
"mustChangePassword": a.MustChangePassword,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/auth/provision — lần đầu: gửi mật khẩu qua email nếu đuôi hợp lệ
|
||||||
|
func ProvisionStaffHandler(db *gorm.DB, mailer mail.Config) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||||
|
}
|
||||||
|
email := normalizeEmail(req.Email)
|
||||||
|
if email == "" || !strings.Contains(email, "@") {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email không hợp lệ"})
|
||||||
|
}
|
||||||
|
if !auth.IsEmailDomainAllowed(db, email) {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Đuôi email không được phép truy cập hệ thống"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing models.StaffAccount
|
||||||
|
err := db.Where("email = ?", email).First(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
if !existing.IsActive {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Tài khoản đã bị khóa"})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "message": "Tài khoản đã tồn tại. Dùng đăng nhập hoặc quên mật khẩu."})
|
||||||
|
}
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
tempPass := auth.RandomPassword(12)
|
||||||
|
hash, err := auth.HashPassword(tempPass)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
staff := models.StaffAccount{
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: hash,
|
||||||
|
MustChangePassword: true,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := db.Create(&staff).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
body := fmt.Sprintf(`<p>Xin chào,</p><p>Tài khoản Simple Care Management đã được tạo.</p>
|
||||||
|
<p><strong>Email:</strong> %s<br/><strong>Mật khẩu tạm:</strong> %s</p>
|
||||||
|
<p>Đăng nhập và đổi mật khẩu ngay lần đầu.</p>`, email, tempPass)
|
||||||
|
if err := mailer.Send(email, "Simple Care — Mật khẩu truy cập", body); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": "Tạo tài khoản ok nhưng gửi mail thất bại: " + err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "message": "Đã gửi mật khẩu tạm tới email của bạn"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/auth/login
|
||||||
|
func LoginStaffHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||||
|
}
|
||||||
|
email := normalizeEmail(req.Email)
|
||||||
|
var staff models.StaffAccount
|
||||||
|
if err := db.Where("email = ?", email).First(&staff).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Email hoặc mật khẩu không đúng"})
|
||||||
|
}
|
||||||
|
if !staff.IsActive {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Tài khoản đã bị khóa"})
|
||||||
|
}
|
||||||
|
if !auth.CheckPassword(staff.PasswordHash, req.Password) {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Email hoặc mật khẩu không đúng"})
|
||||||
|
}
|
||||||
|
token, err := auth.IssueToken(&staff)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"token": token,
|
||||||
|
"mustChangePassword": staff.MustChangePassword,
|
||||||
|
"staff": staffPublic(staff),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/auth/me
|
||||||
|
func MeStaffHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
staffID, ok := c.Locals("staffId").(uint)
|
||||||
|
if !ok || staffID == 0 {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
|
||||||
|
}
|
||||||
|
var staff models.StaffAccount
|
||||||
|
if err := db.First(&staff, staffID).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Account not found"})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"staff": staffPublic(staff)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/auth/change-password
|
||||||
|
func ChangePasswordHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
staffID, ok := c.Locals("staffId").(uint)
|
||||||
|
if !ok || staffID == 0 {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
OldPassword string `json:"oldPassword"`
|
||||||
|
NewPassword string `json:"newPassword"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||||
|
}
|
||||||
|
if len(req.NewPassword) < 8 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Mật khẩu mới tối thiểu 8 ký tự"})
|
||||||
|
}
|
||||||
|
var staff models.StaffAccount
|
||||||
|
if err := db.First(&staff, staffID).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Account not found"})
|
||||||
|
}
|
||||||
|
if !auth.CheckPassword(staff.PasswordHash, req.OldPassword) {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Mật khẩu hiện tại không đúng"})
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
staff.PasswordHash = hash
|
||||||
|
staff.MustChangePassword = false
|
||||||
|
if err := db.Save(&staff).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
token, _ := auth.IssueToken(&staff)
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "token": token, "staff": staffPublic(staff)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/auth/forgot-password
|
||||||
|
func ForgotPasswordHandler(db *gorm.DB, mailer mail.Config) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||||
|
}
|
||||||
|
email := normalizeEmail(req.Email)
|
||||||
|
var staff models.StaffAccount
|
||||||
|
if err := db.Where("email = ?", email).First(&staff).Error; err != nil {
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "message": "Nếu email tồn tại, link đặt lại mật khẩu đã được gửi"})
|
||||||
|
}
|
||||||
|
token := auth.RandomToken(24)
|
||||||
|
row := models.PasswordResetToken{
|
||||||
|
StaffID: staff.ID,
|
||||||
|
Token: token,
|
||||||
|
ExpiresAt: time.Now().Add(2 * time.Hour),
|
||||||
|
}
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf(`<p>Đặt lại mật khẩu Simple Care.</p>
|
||||||
|
<p>Mã đặt lại (hiệu lực 2 giờ): <strong>%s</strong></p>
|
||||||
|
<p>Nhập mã này trên màn hình đặt lại mật khẩu.</p>`, token)
|
||||||
|
_ = mailer.Send(email, "Simple Care — Đặt lại mật khẩu", body)
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "message": "Nếu email tồn tại, mã đặt lại đã được gửi"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/auth/reset-password
|
||||||
|
func ResetPasswordHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
NewPassword string `json:"newPassword"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||||
|
}
|
||||||
|
if len(req.NewPassword) < 8 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Mật khẩu mới tối thiểu 8 ký tự"})
|
||||||
|
}
|
||||||
|
var row models.PasswordResetToken
|
||||||
|
if err := db.Where("token = ? AND used_at IS NULL AND expires_at > ?", req.Token, time.Now()).First(&row).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Mã không hợp lệ hoặc đã hết hạn"})
|
||||||
|
}
|
||||||
|
var staff models.StaffAccount
|
||||||
|
if err := db.First(&staff, row.StaffID).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Tài khoản không tồn tại"})
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
staff.PasswordHash = hash
|
||||||
|
staff.MustChangePassword = false
|
||||||
|
row.UsedAt = &now
|
||||||
|
if err := db.Save(&staff).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
_ = db.Save(&row)
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "message": "Đã đặt lại mật khẩu. Vui lòng đăng nhập."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/admin/email-domains
|
||||||
|
func ListEmailDomainsHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
var rows []models.EmailDomain
|
||||||
|
db.Order("domain asc").Find(&rows)
|
||||||
|
return c.JSON(fiber.Map{"data": rows})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/email-domains
|
||||||
|
func AddEmailDomainHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||||
|
}
|
||||||
|
domain := strings.ToLower(strings.TrimSpace(req.Domain))
|
||||||
|
if domain == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "domain required"})
|
||||||
|
}
|
||||||
|
row := models.EmailDomain{Domain: domain, IsActive: true}
|
||||||
|
if err := db.Where("domain = ?", domain).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/admin/email-domains/:id
|
||||||
|
func DeleteEmailDomainHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
id := c.Params("id")
|
||||||
|
if err := db.Delete(&models.EmailDomain{}, id).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/admin/staff — danh sách tài khoản
|
||||||
|
func ListStaffHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
var rows []models.StaffAccount
|
||||||
|
db.Order("email asc").Find(&rows)
|
||||||
|
out := make([]fiber.Map, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, staffPublic(r))
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"data": out})
|
||||||
|
}
|
||||||
|
}
|
||||||
282
server/internal/handlers/handlers_chat.go
Normal file
282
server/internal/handlers/handlers_chat.go
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/internal/middleware"
|
||||||
|
"server/internal/models"
|
||||||
|
internalWs "server/internal/websocket"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func chatMessageDTO(m models.ChatMessage, staffName string) fiber.Map {
|
||||||
|
return fiber.Map{
|
||||||
|
"id": m.ID,
|
||||||
|
"staffId": m.StaffID,
|
||||||
|
"studentRkId": m.StudentRkID,
|
||||||
|
"senderRole": m.SenderRole,
|
||||||
|
"body": m.Body,
|
||||||
|
"createdAt": m.CreatedAt,
|
||||||
|
"readAt": m.ReadAt,
|
||||||
|
"staffName": staffName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/chat/students?q=
|
||||||
|
func SearchChatStudentsHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
q := strings.TrimSpace(c.Query("q", ""))
|
||||||
|
limit := 30
|
||||||
|
query := db.Model(&models.Student{})
|
||||||
|
if q != "" {
|
||||||
|
like := "%" + q + "%"
|
||||||
|
query = query.Where("full_name LIKE ? OR student_code LIKE ? OR email LIKE ?", like, like, like)
|
||||||
|
}
|
||||||
|
var students []models.Student
|
||||||
|
query.Order("full_name asc").Limit(limit).Find(&students)
|
||||||
|
out := make([]fiber.Map, 0, len(students))
|
||||||
|
for _, s := range students {
|
||||||
|
online := internalWs.Hub.IsStudentOnline(s.RkID)
|
||||||
|
out = append(out, fiber.Map{
|
||||||
|
"studentRkId": s.RkID,
|
||||||
|
"fullName": s.FullName,
|
||||||
|
"studentCode": s.StudentCode,
|
||||||
|
"email": s.Email,
|
||||||
|
"online": online,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"data": out})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/chat/conversations
|
||||||
|
func ListChatConversationsHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
staffID := middleware.StaffIDFromCtx(c)
|
||||||
|
type row struct {
|
||||||
|
StudentRkID int64
|
||||||
|
LastAt time.Time
|
||||||
|
Unread int64
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
db.Raw(`
|
||||||
|
SELECT student_rk_id AS student_rk_id, MAX(created_at) AS last_at,
|
||||||
|
SUM(CASE WHEN sender_role = 'student' AND read_at IS NULL THEN 1 ELSE 0 END) AS unread
|
||||||
|
FROM chat_messages WHERE staff_id = ? GROUP BY student_rk_id ORDER BY last_at DESC LIMIT 50
|
||||||
|
`, staffID).Scan(&rows)
|
||||||
|
|
||||||
|
out := make([]fiber.Map, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
var st models.Student
|
||||||
|
db.Where("rk_id = ?", r.StudentRkID).First(&st)
|
||||||
|
var last models.ChatMessage
|
||||||
|
db.Where("staff_id = ? AND student_rk_id = ?", staffID, r.StudentRkID).Order("id desc").First(&last)
|
||||||
|
out = append(out, fiber.Map{
|
||||||
|
"studentRkId": r.StudentRkID,
|
||||||
|
"fullName": st.FullName,
|
||||||
|
"studentCode": st.StudentCode,
|
||||||
|
"lastMessage": last.Body,
|
||||||
|
"lastAt": r.LastAt,
|
||||||
|
"unread": r.Unread,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"data": out})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/chat/messages/:studentRkId
|
||||||
|
func ListChatMessagesHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
staffID := middleware.StaffIDFromCtx(c)
|
||||||
|
studentRkID, _ := strconv.ParseInt(c.Params("studentRkId"), 10, 64)
|
||||||
|
if studentRkID <= 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid student id"})
|
||||||
|
}
|
||||||
|
var staff models.StaffAccount
|
||||||
|
_ = db.First(&staff, staffID).Error
|
||||||
|
var msgs []models.ChatMessage
|
||||||
|
db.Where("staff_id = ? AND student_rk_id = ?", staffID, studentRkID).Order("id asc").Limit(200).Find(&msgs)
|
||||||
|
now := time.Now()
|
||||||
|
db.Model(&models.ChatMessage{}).
|
||||||
|
Where("staff_id = ? AND student_rk_id = ? AND sender_role = 'student' AND read_at IS NULL", staffID, studentRkID).
|
||||||
|
Update("read_at", now)
|
||||||
|
out := make([]fiber.Map, 0, len(msgs))
|
||||||
|
for _, m := range msgs {
|
||||||
|
out = append(out, chatMessageDTO(m, staff.FullName))
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"data": out})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/chat/messages
|
||||||
|
func SendChatMessageHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
staffID := middleware.StaffIDFromCtx(c)
|
||||||
|
var req struct {
|
||||||
|
StudentRkID int64 `json:"studentRkId"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(req.Body)
|
||||||
|
if body == "" || req.StudentRkID <= 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId and body required"})
|
||||||
|
}
|
||||||
|
var staff models.StaffAccount
|
||||||
|
if err := db.First(&staff, staffID).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "staff not found"})
|
||||||
|
}
|
||||||
|
msg := models.ChatMessage{
|
||||||
|
StaffID: staffID,
|
||||||
|
StudentRkID: req.StudentRkID,
|
||||||
|
SenderRole: "staff",
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
if err := db.Create(&msg).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
dto := chatMessageDTO(msg, staff.FullName)
|
||||||
|
internalWs.Hub.PushChatToStudent(req.StudentRkID, dto)
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "data": dto})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/student/chat/conversations?studentRkId=
|
||||||
|
func StudentListChatConversationsHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
studentRkID, _ := strconv.ParseInt(c.Query("studentRkId"), 10, 64)
|
||||||
|
if studentRkID <= 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId required"})
|
||||||
|
}
|
||||||
|
type row struct {
|
||||||
|
StaffID uint
|
||||||
|
LastAt time.Time
|
||||||
|
Unread int64
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
db.Raw(`
|
||||||
|
SELECT staff_id AS staff_id, MAX(created_at) AS last_at,
|
||||||
|
SUM(CASE WHEN sender_role = 'staff' AND read_at IS NULL THEN 1 ELSE 0 END) AS unread
|
||||||
|
FROM chat_messages WHERE student_rk_id = ? GROUP BY staff_id ORDER BY last_at DESC LIMIT 50
|
||||||
|
`, studentRkID).Scan(&rows)
|
||||||
|
|
||||||
|
out := make([]fiber.Map, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
var staff models.StaffAccount
|
||||||
|
name, email := "", ""
|
||||||
|
if db.First(&staff, r.StaffID).Error == nil {
|
||||||
|
name = staff.FullName
|
||||||
|
email = staff.Email
|
||||||
|
if name == "" {
|
||||||
|
name = email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var last models.ChatMessage
|
||||||
|
db.Where("staff_id = ? AND student_rk_id = ?", r.StaffID, studentRkID).Order("id desc").First(&last)
|
||||||
|
out = append(out, fiber.Map{
|
||||||
|
"staffId": r.StaffID,
|
||||||
|
"staffName": name,
|
||||||
|
"staffEmail": email,
|
||||||
|
"lastMessage": last.Body,
|
||||||
|
"lastAt": r.LastAt,
|
||||||
|
"unread": r.Unread,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"data": out})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/student/chat/messages?studentRkId=&staffId=
|
||||||
|
func StudentListChatHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
studentRkID, _ := strconv.ParseInt(c.Query("studentRkId"), 10, 64)
|
||||||
|
staffID, _ := strconv.ParseUint(c.Query("staffId", "0"), 10, 64)
|
||||||
|
if studentRkID <= 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId required"})
|
||||||
|
}
|
||||||
|
query := db.Where("student_rk_id = ?", studentRkID)
|
||||||
|
if staffID > 0 {
|
||||||
|
query = query.Where("staff_id = ?", staffID)
|
||||||
|
}
|
||||||
|
var msgs []models.ChatMessage
|
||||||
|
query.Order("id asc").Limit(200).Find(&msgs)
|
||||||
|
now := time.Now()
|
||||||
|
readQ := db.Model(&models.ChatMessage{}).
|
||||||
|
Where("student_rk_id = ? AND sender_role = 'staff' AND read_at IS NULL", studentRkID)
|
||||||
|
if staffID > 0 {
|
||||||
|
readQ = readQ.Where("staff_id = ?", staffID)
|
||||||
|
}
|
||||||
|
readQ.Update("read_at", now)
|
||||||
|
|
||||||
|
staffNames := map[uint]string{}
|
||||||
|
out := make([]fiber.Map, 0, len(msgs))
|
||||||
|
for _, m := range msgs {
|
||||||
|
name := staffNames[m.StaffID]
|
||||||
|
if name == "" {
|
||||||
|
var st models.StaffAccount
|
||||||
|
if db.First(&st, m.StaffID).Error == nil {
|
||||||
|
name = st.FullName
|
||||||
|
if name == "" {
|
||||||
|
name = st.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
staffNames[m.StaffID] = name
|
||||||
|
}
|
||||||
|
out = append(out, chatMessageDTO(m, name))
|
||||||
|
}
|
||||||
|
var lastStaffID uint
|
||||||
|
for i := len(msgs) - 1; i >= 0; i-- {
|
||||||
|
if msgs[i].SenderRole == "staff" {
|
||||||
|
lastStaffID = msgs[i].StaffID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"data": out, "replyStaffId": lastStaffID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/student/chat/messages
|
||||||
|
func StudentSendChatHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
StudentRkID int64 `json:"studentRkId"`
|
||||||
|
StaffID uint `json:"staffId"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(req.Body)
|
||||||
|
if body == "" || req.StudentRkID <= 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid payload"})
|
||||||
|
}
|
||||||
|
staffID := req.StaffID
|
||||||
|
if staffID == 0 {
|
||||||
|
var last models.ChatMessage
|
||||||
|
if err := db.Where("student_rk_id = ? AND sender_role = 'staff'", req.StudentRkID).
|
||||||
|
Order("id desc").First(&last).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "chưa có giáo viên nhắn tin"})
|
||||||
|
}
|
||||||
|
staffID = last.StaffID
|
||||||
|
}
|
||||||
|
msg := models.ChatMessage{
|
||||||
|
StaffID: staffID,
|
||||||
|
StudentRkID: req.StudentRkID,
|
||||||
|
SenderRole: "student",
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
if err := db.Create(&msg).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
var staff models.StaffAccount
|
||||||
|
_ = db.First(&staff, staffID).Error
|
||||||
|
dto := chatMessageDTO(msg, staff.FullName)
|
||||||
|
internalWs.Hub.PushChatToStaff(staffID, dto)
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "data": dto})
|
||||||
|
}
|
||||||
|
}
|
||||||
91
server/internal/mail/mail.go
Normal file
91
server/internal/mail/mail.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
Secure bool
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
From string
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfigFromEnv() Config {
|
||||||
|
return Config{
|
||||||
|
Host: strings.TrimSpace(os.Getenv("MAIL_HOST")),
|
||||||
|
Port: strings.TrimSpace(os.Getenv("MAIL_PORT")),
|
||||||
|
Secure: strings.EqualFold(os.Getenv("MAIL_SECURE"), "true"),
|
||||||
|
User: strings.TrimSpace(os.Getenv("MAIL_AUTH_USER")),
|
||||||
|
Password: strings.Trim(strings.TrimSpace(os.Getenv("MAIL_AUTH_PASS")), `"`),
|
||||||
|
From: strings.TrimSpace(os.Getenv("MAIL_FROM")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Enabled() bool {
|
||||||
|
return c.Host != "" && c.Port != "" && c.User != "" && c.Password != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) fromAddr() string {
|
||||||
|
if c.From != "" {
|
||||||
|
return c.From
|
||||||
|
}
|
||||||
|
return c.User
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Send(to, subject, body string) error {
|
||||||
|
if !c.Enabled() {
|
||||||
|
return fmt.Errorf("mail chưa cấu hình (MAIL_HOST, MAIL_AUTH_USER, MAIL_AUTH_PASS)")
|
||||||
|
}
|
||||||
|
msg := strings.Join([]string{
|
||||||
|
"From: Simple Care <" + c.fromAddr() + ">",
|
||||||
|
"To: " + to,
|
||||||
|
"Subject: " + subject,
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
"Content-Type: text/html; charset=UTF-8",
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
}, "\r\n")
|
||||||
|
|
||||||
|
addr := net.JoinHostPort(c.Host, c.Port)
|
||||||
|
auth := smtp.PlainAuth("", c.User, c.Password, c.Host)
|
||||||
|
|
||||||
|
if c.Secure {
|
||||||
|
tlsConfig := &tls.Config{ServerName: c.Host}
|
||||||
|
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client, err := smtp.NewClient(conn, c.Host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
if err := client.Auth(auth); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := client.Mail(c.fromAddr()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := client.Rcpt(to); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte(msg)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return smtp.SendMail(addr, auth, c.fromAddr(), []string{to}, []byte(msg))
|
||||||
|
}
|
||||||
33
server/internal/middleware/staff.go
Normal file
33
server/internal/middleware/staff.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"server/internal/auth"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RequireStaff() fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
header := c.Get("Authorization")
|
||||||
|
if header == "" || !strings.HasPrefix(header, "Bearer ") {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||||
|
claims, err := auth.ParseToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"})
|
||||||
|
}
|
||||||
|
c.Locals("staffId", claims.StaffID)
|
||||||
|
c.Locals("staffEmail", claims.Email)
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func StaffIDFromCtx(c *fiber.Ctx) uint {
|
||||||
|
if v, ok := c.Locals("staffId").(uint); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
52
server/internal/models/staff.go
Normal file
52
server/internal/models/staff.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// StaffAccount — tài khoản thầy cô truy cập management
|
||||||
|
type StaffAccount struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
Email string `gorm:"column:email;size:255;not null;uniqueIndex" json:"email"`
|
||||||
|
PasswordHash string `gorm:"column:password_hash;size:255;not null" json:"-"`
|
||||||
|
FullName string `gorm:"column:full_name;size:255" json:"fullName"`
|
||||||
|
MustChangePassword bool `gorm:"column:must_change_password;not null;default:true" json:"mustChangePassword"`
|
||||||
|
IsActive bool `gorm:"column:is_active;not null;default:true" json:"isActive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (StaffAccount) TableName() string { return "staff_accounts" }
|
||||||
|
|
||||||
|
// EmailDomain — đuôi email được phép đăng ký staff
|
||||||
|
type EmailDomain struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
Domain string `gorm:"column:domain;size:128;not null;uniqueIndex" json:"domain"`
|
||||||
|
IsActive bool `gorm:"column:is_active;not null;default:true" json:"isActive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (EmailDomain) TableName() string { return "email_domains" }
|
||||||
|
|
||||||
|
// PasswordResetToken — quên mật khẩu
|
||||||
|
type PasswordResetToken struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
StaffID uint `gorm:"column:staff_id;not null;index" json:"staffId"`
|
||||||
|
Token string `gorm:"column:token;size:64;not null;uniqueIndex" json:"-"`
|
||||||
|
ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expiresAt"`
|
||||||
|
UsedAt *time.Time `gorm:"column:used_at" json:"usedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (PasswordResetToken) TableName() string { return "password_reset_tokens" }
|
||||||
|
|
||||||
|
// ChatMessage — tin nhắn thầy cô ↔ sinh viên
|
||||||
|
type ChatMessage struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
StaffID uint `gorm:"column:staff_id;not null;index:idx_chat_thread,priority:1" json:"staffId"`
|
||||||
|
StudentRkID int64 `gorm:"column:student_rk_id;not null;index:idx_chat_thread,priority:2" json:"studentRkId"`
|
||||||
|
SenderRole string `gorm:"column:sender_role;size:16;not null" json:"senderRole"` // staff | student
|
||||||
|
Body string `gorm:"column:body;type:text;not null" json:"body"`
|
||||||
|
ReadAt *time.Time `gorm:"column:read_at" json:"readAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChatMessage) TableName() string { return "chat_messages" }
|
||||||
@@ -20,6 +20,7 @@ type SocketClient struct {
|
|||||||
Conn *websocket.Conn
|
Conn *websocket.Conn
|
||||||
StudentID int64
|
StudentID int64
|
||||||
ClassID int64
|
ClassID int64
|
||||||
|
StaffID uint
|
||||||
Role string // "student" | "teacher"
|
Role string // "student" | "teacher"
|
||||||
Addr string
|
Addr string
|
||||||
}
|
}
|
||||||
@@ -28,15 +29,49 @@ type WsHub struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
students map[int64]*SocketClient
|
students map[int64]*SocketClient
|
||||||
teachers map[string]*SocketClient
|
teachers map[string]*SocketClient
|
||||||
|
teachersByStaff map[uint][]string // staffId -> teacher connection addresses
|
||||||
subscribers map[int64][]string // studentId -> list of teacher connection addresses
|
subscribers map[int64][]string // studentId -> list of teacher connection addresses
|
||||||
}
|
}
|
||||||
|
|
||||||
var Hub = &WsHub{
|
var Hub = &WsHub{
|
||||||
students: make(map[int64]*SocketClient),
|
students: make(map[int64]*SocketClient),
|
||||||
teachers: make(map[string]*SocketClient),
|
teachers: make(map[string]*SocketClient),
|
||||||
|
teachersByStaff: make(map[uint][]string),
|
||||||
subscribers: make(map[int64][]string),
|
subscribers: make(map[int64][]string),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
_, ok := h.students[studentRkID]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WsHub) PushChatToStudent(studentRkID int64, data map[string]any) {
|
||||||
|
h.mu.RLock()
|
||||||
|
client, ok := h.students[studentRkID]
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if !ok || client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = client.Conn.WriteJSON(SocketMsg{Event: "chat:message", Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WsHub) PushChatToStaff(staffID uint, data map[string]any) {
|
||||||
|
h.mu.RLock()
|
||||||
|
addrs := append([]string(nil), h.teachersByStaff[staffID]...)
|
||||||
|
h.mu.RUnlock()
|
||||||
|
msg := SocketMsg{Event: "chat:message", Data: data}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
h.mu.RLock()
|
||||||
|
t, found := h.teachers[addr]
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if found && t != nil {
|
||||||
|
_ = t.Conn.WriteJSON(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WsHub) GetOnlineStudentIDs(classID int64) []int64 {
|
func (h *WsHub) GetOnlineStudentIDs(classID int64) []int64 {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
@@ -63,7 +98,10 @@ func (h *WsHub) Register(c *SocketClient) {
|
|||||||
log.Printf("[WS] Student %d registered (Address: %s, Class: %d)", c.StudentID, c.Addr, c.ClassID)
|
log.Printf("[WS] Student %d registered (Address: %s, Class: %d)", c.StudentID, c.Addr, c.ClassID)
|
||||||
} else if c.Role == "teacher" {
|
} else if c.Role == "teacher" {
|
||||||
h.teachers[c.Addr] = c
|
h.teachers[c.Addr] = c
|
||||||
log.Printf("[WS] Teacher registered (Address: %s)", c.Addr)
|
if c.StaffID > 0 {
|
||||||
|
h.teachersByStaff[c.StaffID] = append(h.teachersByStaff[c.StaffID], c.Addr)
|
||||||
|
}
|
||||||
|
log.Printf("[WS] Teacher registered (Address: %s, Staff: %d)", c.Addr, c.StaffID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +127,20 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
|||||||
}
|
}
|
||||||
} else if c.Role == "teacher" {
|
} else if c.Role == "teacher" {
|
||||||
delete(h.teachers, c.Addr)
|
delete(h.teachers, c.Addr)
|
||||||
|
if c.StaffID > 0 {
|
||||||
|
list := h.teachersByStaff[c.StaffID]
|
||||||
|
next := list[:0]
|
||||||
|
for _, addr := range list {
|
||||||
|
if addr != c.Addr {
|
||||||
|
next = append(next, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(next) == 0 {
|
||||||
|
delete(h.teachersByStaff, c.StaffID)
|
||||||
|
} else {
|
||||||
|
h.teachersByStaff[c.StaffID] = next
|
||||||
|
}
|
||||||
|
}
|
||||||
log.Printf("[WS] Teacher %s disconnected", c.Addr)
|
log.Printf("[WS] Teacher %s disconnected", c.Addr)
|
||||||
|
|
||||||
// Dọn dẹp subscriptions của giáo viên này
|
// Dọn dẹp subscriptions của giáo viên này
|
||||||
@@ -206,9 +258,11 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
|||||||
role := c.Query("role", "student")
|
role := c.Query("role", "student")
|
||||||
studentIDStr := c.Query("studentId", "0")
|
studentIDStr := c.Query("studentId", "0")
|
||||||
classIDStr := c.Query("classId", "0")
|
classIDStr := c.Query("classId", "0")
|
||||||
|
staffIDStr := c.Query("staffId", "0")
|
||||||
|
|
||||||
studentID, _ := strconv.ParseInt(studentIDStr, 10, 64)
|
studentID, _ := strconv.ParseInt(studentIDStr, 10, 64)
|
||||||
classID, _ := strconv.ParseInt(classIDStr, 10, 64)
|
classID, _ := strconv.ParseInt(classIDStr, 10, 64)
|
||||||
|
staffID64, _ := strconv.ParseUint(staffIDStr, 10, 64)
|
||||||
|
|
||||||
if role == "student" && db != nil {
|
if role == "student" && db != nil {
|
||||||
resolvedClassID := internalDb.FindActiveClassForStudent(db, studentID)
|
resolvedClassID := internalDb.FindActiveClassForStudent(db, studentID)
|
||||||
@@ -221,6 +275,7 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
|||||||
Conn: c,
|
Conn: c,
|
||||||
StudentID: studentID,
|
StudentID: studentID,
|
||||||
ClassID: classID,
|
ClassID: classID,
|
||||||
|
StaffID: uint(staffID64),
|
||||||
Role: role,
|
Role: role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
115
server/main.go
115
server/main.go
@@ -6,6 +6,8 @@ import (
|
|||||||
|
|
||||||
"server/internal/db"
|
"server/internal/db"
|
||||||
"server/internal/handlers"
|
"server/internal/handlers"
|
||||||
|
"server/internal/mail"
|
||||||
|
"server/internal/middleware"
|
||||||
"server/internal/qldt"
|
"server/internal/qldt"
|
||||||
"server/internal/syncjobs"
|
"server/internal/syncjobs"
|
||||||
internalWs "server/internal/websocket"
|
internalWs "server/internal/websocket"
|
||||||
@@ -47,6 +49,7 @@ func main() {
|
|||||||
qldtClient := qldt.NewClient(qldtBaseURL, qldtOrigin)
|
qldtClient := qldt.NewClient(qldtBaseURL, qldtOrigin)
|
||||||
classesJob := syncjobs.NewClassesSyncJob()
|
classesJob := syncjobs.NewClassesSyncJob()
|
||||||
studentsJob := syncjobs.NewStudentsSyncJob()
|
studentsJob := syncjobs.NewStudentsSyncJob()
|
||||||
|
mailer := mail.LoadConfigFromEnv()
|
||||||
|
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
AppName: "Simple Care Sync Backend",
|
AppName: "Simple Care Sync Backend",
|
||||||
@@ -72,54 +75,82 @@ func main() {
|
|||||||
|
|
||||||
api := app.Group("/api")
|
api := app.Group("/api")
|
||||||
|
|
||||||
// Dashboard Stats
|
// Auth công khai (management)
|
||||||
api.Get("/stats", handlers.GetStatsHandler(gormDB))
|
api.Post("/auth/provision", handlers.ProvisionStaffHandler(gormDB, mailer))
|
||||||
|
api.Post("/auth/login", handlers.LoginStaffHandler(gormDB))
|
||||||
|
api.Post("/auth/forgot-password", handlers.ForgotPasswordHandler(gormDB, mailer))
|
||||||
|
api.Post("/auth/reset-password", handlers.ResetPasswordHandler(gormDB))
|
||||||
|
|
||||||
// Classes endpoints
|
// Student client (không cần JWT staff)
|
||||||
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))
|
|
||||||
|
|
||||||
// Students endpoints
|
|
||||||
api.Get("/students", handlers.ListAllStudentsHandler(gormDB))
|
|
||||||
|
|
||||||
// Sync endpoints
|
|
||||||
api.Post("/sync/classes/start", handlers.StartClassesSyncHandler(gormDB, qldtClient, classesJob, qldtToken))
|
|
||||||
api.Get("/sync/classes/status", handlers.GetClassesSyncStatusHandler(classesJob))
|
|
||||||
api.Post("/sync/students/start", handlers.StartStudentsSyncHandler(gormDB, qldtClient, studentsJob, qldtToken))
|
|
||||||
api.Get("/sync/students/status", handlers.GetStudentsSyncStatusHandler(studentsJob))
|
|
||||||
|
|
||||||
// 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.Post("/student/sync-log", handlers.SyncStudentSessionLogHandler(gormDB))
|
||||||
api.Get("/student/status", handlers.GetStudentStatusHandler(gormDB))
|
api.Get("/student/status", handlers.GetStudentStatusHandler(gormDB))
|
||||||
api.Get("/student/wifi-policy", handlers.GetStudentWifiPolicyHandler(gormDB))
|
api.Get("/student/wifi-policy", handlers.GetStudentWifiPolicyHandler(gormDB))
|
||||||
api.Post("/student/report-wifi", handlers.ReportWifiHandler(gormDB))
|
api.Post("/student/report-wifi", handlers.ReportWifiHandler(gormDB))
|
||||||
api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB))
|
api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB))
|
||||||
|
api.Get("/student/chat/messages", handlers.StudentListChatHandler(gormDB))
|
||||||
|
api.Get("/student/chat/conversations", handlers.StudentListChatConversationsHandler(gormDB))
|
||||||
|
api.Post("/student/chat/messages", handlers.StudentSendChatHandler(gormDB))
|
||||||
|
|
||||||
|
// Health check (public)
|
||||||
|
api.Get("/health", func(c *fiber.Ctx) error {
|
||||||
|
return c.JSON(fiber.Map{"ok": true})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Management — yêu cầu đăng nhập staff
|
||||||
|
staff := api.Group("", middleware.RequireStaff())
|
||||||
|
staff.Get("/auth/me", handlers.MeStaffHandler(gormDB))
|
||||||
|
staff.Post("/auth/change-password", handlers.ChangePasswordHandler(gormDB))
|
||||||
|
|
||||||
|
staff.Get("/stats", handlers.GetStatsHandler(gormDB))
|
||||||
|
|
||||||
|
// Classes endpoints
|
||||||
|
staff.Get("/classes", handlers.ListClassesHandler(gormDB))
|
||||||
|
staff.Get("/classes/schedules", handlers.ListActiveSchedulesHandler(gormDB))
|
||||||
|
staff.Get("/classes/:rkId", handlers.GetClassHandler(gormDB))
|
||||||
|
staff.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB))
|
||||||
|
staff.Get("/classes/:rkId/students", handlers.ListClassStudentsHandler(gormDB))
|
||||||
|
|
||||||
|
// Students endpoints
|
||||||
|
staff.Get("/students", handlers.ListAllStudentsHandler(gormDB))
|
||||||
|
|
||||||
|
// Sync endpoints
|
||||||
|
staff.Post("/sync/classes/start", handlers.StartClassesSyncHandler(gormDB, qldtClient, classesJob, qldtToken))
|
||||||
|
staff.Get("/sync/classes/status", handlers.GetClassesSyncStatusHandler(classesJob))
|
||||||
|
staff.Post("/sync/students/start", handlers.StartStudentsSyncHandler(gormDB, qldtClient, studentsJob, qldtToken))
|
||||||
|
staff.Get("/sync/students/status", handlers.GetStudentsSyncStatusHandler(studentsJob))
|
||||||
|
|
||||||
|
// Learning Management endpoints
|
||||||
|
staff.Get("/classes/:rkId/schedule", handlers.GetClassScheduleHandler(gormDB))
|
||||||
|
staff.Post("/classes/:rkId/schedule", handlers.SaveClassScheduleHandler(gormDB))
|
||||||
|
staff.Delete("/classes/:rkId/schedule", handlers.DeleteClassScheduleHandler(gormDB))
|
||||||
|
staff.Get("/classes/:rkId/allowed-apps", handlers.GetAllowedAppsHandler(gormDB))
|
||||||
|
staff.Post("/classes/:rkId/allowed-apps", handlers.SaveAllowedAppsHandler(gormDB))
|
||||||
|
staff.Get("/app-pool", handlers.ListAppPoolHandler(gormDB))
|
||||||
|
|
||||||
|
staff.Get("/wifi-pool", handlers.ListWifiPoolHandler(gormDB))
|
||||||
|
staff.Get("/network/accepted-wifis", handlers.ListAcceptedWifisHandler(gormDB))
|
||||||
|
staff.Post("/network/accepted-wifis", handlers.SaveAcceptedWifisHandler(gormDB))
|
||||||
|
staff.Post("/network/accepted-wifis/add", handlers.AddAcceptedWifiHandler(gormDB))
|
||||||
|
staff.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(gormDB))
|
||||||
|
staff.Get("/classes/:rkId/session-logs", handlers.ListClassSessionLogsHandler(gormDB))
|
||||||
|
staff.Get("/classes/:rkId/online-students", handlers.GetOnlineStudentsHandler(gormDB))
|
||||||
|
|
||||||
|
staff.Get("/classes/:rkId/courses", handlers.GetClassCoursesHandler(gormDB, qldtClient, qldtToken))
|
||||||
|
staff.Post("/classes/:rkId/schedule/apply-template", handlers.ApplyScheduleTemplateHandler(gormDB))
|
||||||
|
staff.Get("/classes/:rkId/attendance", handlers.GetClassAttendanceHandler(gormDB))
|
||||||
|
staff.Get("/classes/:rkId/attendance/shifts", handlers.ListAttendanceShiftsHandler(gormDB))
|
||||||
|
staff.Put("/classes/:rkId/attendance/status", handlers.UpdateAttendanceStatusHandler(gormDB))
|
||||||
|
staff.Post("/classes/:rkId/attendance/push-qldt", handlers.PushAttendanceToQLDTHandler(gormDB, qldtClient, qldtToken))
|
||||||
|
|
||||||
|
// Chat & quản lý tài khoản
|
||||||
|
staff.Get("/chat/students", handlers.SearchChatStudentsHandler(gormDB))
|
||||||
|
staff.Get("/chat/conversations", handlers.ListChatConversationsHandler(gormDB))
|
||||||
|
staff.Get("/chat/messages/:studentRkId", handlers.ListChatMessagesHandler(gormDB))
|
||||||
|
staff.Post("/chat/messages", handlers.SendChatMessageHandler(gormDB))
|
||||||
|
staff.Get("/admin/email-domains", handlers.ListEmailDomainsHandler(gormDB))
|
||||||
|
staff.Post("/admin/email-domains", handlers.AddEmailDomainHandler(gormDB))
|
||||||
|
staff.Delete("/admin/email-domains/:id", handlers.DeleteEmailDomainHandler(gormDB))
|
||||||
|
staff.Get("/admin/staff", handlers.ListStaffHandler(gormDB))
|
||||||
|
|
||||||
port := getEnv("PORT", "8080")
|
port := getEnv("PORT", "8080")
|
||||||
log.Printf("Server starting on port %s...", port)
|
log.Printf("Server starting on port %s...", port)
|
||||||
|
|||||||
Reference in New Issue
Block a user