From 6e284e66fa0ebad8403dd0fee62fb4806e945bc8 Mon Sep 17 00:00:00 2001 From: PhuocNTB Date: Tue, 30 Jun 2026 10:43:11 +0700 Subject: [PATCH] ass --- client/app.go | 25 ++++--- client/frontend/src/main.js | 34 ++++++++- management/src/App.tsx | 2 + management/src/api.ts | 1 + management/src/components/ChatWidget.tsx | 81 ++++++++++++++-------- management/src/hooks/useStaffChatSocket.ts | 71 +++++++++++++++++++ management/src/index.css | 27 ++++++++ management/src/utils/notifySound.ts | 37 ++++++++++ server/internal/handlers/handlers_chat.go | 4 +- server/internal/websocket/websocket.go | 16 ++--- 10 files changed, 247 insertions(+), 51 deletions(-) create mode 100644 management/src/hooks/useStaffChatSocket.ts create mode 100644 management/src/utils/notifySound.ts diff --git a/client/app.go b/client/app.go index 79471f5..7399751 100644 --- a/client/app.go +++ b/client/app.go @@ -973,17 +973,20 @@ func (a *App) connectWS() { case "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"], - }) + senderRole, _ := msg.Data["senderRole"].(string) + if senderRole == "staff" { + 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) } diff --git a/client/frontend/src/main.js b/client/frontend/src/main.js index 5cd42f7..0a69052 100644 --- a/client/frontend/src/main.js +++ b/client/frontend/src/main.js @@ -27,6 +27,34 @@ let chatOpen = false; let chatMessages = []; let chatConversations = []; let activeStaffId = 0; +let chatAudioCtx = null; + +function playChatSound() { + try { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!chatAudioCtx) chatAudioCtx = new Ctx(); + const ctx = chatAudioCtx; + if (ctx.state === 'suspended') ctx.resume(); + const tone = (freq, start, dur) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(0.15, start + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, start + dur); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(start); + osc.stop(start + dur + 0.02); + }; + const t = ctx.currentTime; + tone(880, t, 0.12); + tone(1174, t + 0.14, 0.14); + } catch { + /* ignore */ + } +} // Quản lý webcam let webcamStream = null; @@ -51,14 +79,18 @@ function init() { window.runtime.EventsOn('start_webcam_stream', startWebcam); window.runtime.EventsOn('stop_webcam_stream', stopWebcam); - window.runtime.EventsOn('chat:message', () => { + window.runtime.EventsOn('chat:message', (data) => { updateChatBadge(); loadChatConversations().catch(console.error); if (chatOpen && activeStaffId) loadChatMessages(activeStaffId).catch(console.error); + if (data?.senderRole === 'staff') { + playChatSound(); + } }); window.runtime.EventsOn('chat:notify', (data) => { updateChatBadge(); showChatToast(data); + playChatSound(); }); checkLogin(); } diff --git a/management/src/App.tsx b/management/src/App.tsx index 487a83b..c5e331b 100644 --- a/management/src/App.tsx +++ b/management/src/App.tsx @@ -6,6 +6,7 @@ import { LearningTab } from './components/LearningTab'; import { NetworkTab } from './components/NetworkTab'; import { AccountsTab } from './components/AccountsTab'; import { ChatWidget } from './components/ChatWidget'; +import { StaffChatSocket } from './hooks/useStaffChatSocket'; import { ClassWorkspace } from './components/ClassWorkspace'; import { NavHistoryBar, useRoute } from './components/NavHistoryBar'; import { goBack, navigate, parseRoute, pushNav, TAB_LABELS, type TabId } from './navigation'; @@ -186,6 +187,7 @@ function App() { )} + ); diff --git a/management/src/api.ts b/management/src/api.ts index 282f437..08859e4 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -113,6 +113,7 @@ export interface ChatStudent { export interface ChatMessage { id: number; staffId: number; + targetStaffId?: number; studentRkId: number; senderRole: 'staff' | 'student'; body: string; diff --git a/management/src/components/ChatWidget.tsx b/management/src/components/ChatWidget.tsx index 75863fd..c37da21 100644 --- a/management/src/components/ChatWidget.tsx +++ b/management/src/components/ChatWidget.tsx @@ -2,6 +2,7 @@ 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'; +import { onStaffChatMessage, playChatSound } from '../hooks/useStaffChatSocket'; export function ChatWidget() { const { staff } = useAuth(); @@ -14,12 +15,13 @@ export function ChatWidget() { const [draft, setDraft] = useState(''); const [sending, setSending] = useState(false); const [pickerOpen, setPickerOpen] = useState(false); + const [toast, setToast] = useState<{ title: string; body: string } | null>(null); const listRef = useRef(null); const activeRef = useRef(null); + const openRef = useRef(false); - useEffect(() => { - activeRef.current = active; - }, [active]); + useEffect(() => { activeRef.current = active; }, [active]); + useEffect(() => { openRef.current = open; }, [open]); const scrollBottom = () => { requestAnimationFrame(() => { @@ -32,6 +34,7 @@ export function ChatWidget() { const loadConversations = useCallback(async () => { const res = await apiChat.listConversations(); setConversations(res.data); + return res.data; }, []); const loadMessages = useCallback(async (studentRkId: number) => { @@ -46,6 +49,46 @@ export function ChatWidget() { setStudents(res.data); }, []); + const showToast = (title: string, body: string) => { + setToast({ title, body }); + setTimeout(() => setToast(null), 4500); + }; + + const handleIncoming = useCallback((msg: ChatMessage) => { + const studentId = Number(msg.studentRkId); + const current = activeRef.current; + + void loadConversations(); + + if (msg.senderRole === 'student') { + playChatSound(); + const preview = msg.body?.slice(0, 80) || 'Tin nhắn mới'; + if (!openRef.current || !current || Number(current.studentRkId) !== studentId) { + showToast('Sinh viên nhắn tin', preview); + } + } + + if (current && Number(current.studentRkId) === studentId) { + setMessages((prev) => { + if (prev.some((m) => m.id === msg.id)) return prev; + return [...prev, msg]; + }); + scrollBottom(); + } + }, [loadConversations]); + + useEffect(() => { + const off = onStaffChatMessage(handleIncoming); + return () => { off(); }; + }, [handleIncoming]); + + useEffect(() => { + if (!staff?.id) return; + loadConversations().catch(console.error); + const t = setInterval(() => loadConversations().catch(console.error), 5000); + return () => clearInterval(t); + }, [staff?.id, loadConversations]); + const pickStudent = async (s: ChatStudent) => { setActive(s); setPickerOpen(false); @@ -69,32 +112,6 @@ export function ChatWidget() { 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); @@ -113,6 +130,12 @@ export function ChatWidget() { const dock = (
+ {toast && ( +
+ {toast.title} + {toast.body} +
+ )} {!open ? (