diff --git a/client/app.go b/client/app.go index 7399751..773d125 100644 --- a/client/app.go +++ b/client/app.go @@ -975,18 +975,21 @@ func (a *App) connectWS() { case "chat:message": senderRole, _ := msg.Data["senderRole"].(string) if senderRole == "staff" { + from := "Giảng viên" + if n, ok := msg.Data["staffName"].(string); ok && n != "" { + from = n + } + preview := "" + if b, ok := msg.Data["body"].(string); ok { + preview = b + } 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"], - }) } + a.alertChatIncoming(from, preview) } runtime.EventsEmit(a.ctx, "chat:message", msg.Data) } @@ -1059,6 +1062,32 @@ func (a *App) stopScreenshotStream() { log.Println("[WS] Screenshot screen-streaming stopped.") } +func (a *App) alertChatIncoming(from, preview string) { + if preview == "" { + preview = "Bạn có tin nhắn mới" + } + if len(preview) > 120 { + preview = preview[:120] + "…" + } + winapi.PlayNotifySound() + winapi.ActivateAppWindow("Simple Care") + runtime.WindowUnminimise(a.ctx) + runtime.WindowShow(a.ctx) + a.mu.Lock() + unread := a.chatUnread + staffID := a.replyStaffID + a.mu.Unlock() + runtime.EventsEmit(a.ctx, "chat:notify", map[string]any{ + "unread": unread, + "preview": preview, + "from": from, + "staffId": staffID, + }) +} + +// UnlockChatAudio — gọi từ UI sau lần click đầu để mở khóa Web Audio (dự phòng) +func (a *App) UnlockChatAudio() {} + func (a *App) GetChatUnread() int { a.mu.Lock() defer a.mu.Unlock() diff --git a/client/frontend/src/app.css b/client/frontend/src/app.css index 1e30cf4..7295705 100644 --- a/client/frontend/src/app.css +++ b/client/frontend/src/app.css @@ -490,127 +490,148 @@ body { white-space: nowrap; } -/* Student chat messenger */ +/* Student chat — modal 2 panel giống web */ .student-chat-dock { position: fixed; - right: 12px; - bottom: 12px; - z-index: 1000; + right: 16px; + bottom: 16px; + z-index: 2000; + pointer-events: none; } -.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-dock > * { + pointer-events: auto; } .student-chat-fab { position: relative; - width: 48px; - height: 48px; + width: 50px; + height: 50px; 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); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28); +} + +.student-chat-fab.is-hidden { + display: none; +} + +.student-chat-fab--pulse { + animation: chat-fab-pulse 0.6s ease 2; +} + +@keyframes chat-fab-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); box-shadow: 0 0 0 8px rgba(187, 33, 38, 0.25); } } .student-chat-badge { position: absolute; - top: -2px; - right: -2px; - min-width: 16px; - height: 16px; - padding: 0 4px; - border-radius: 8px; + top: -3px; + right: -3px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; background: #ef4444; color: #fff; font-size: 0.62rem; font-weight: 700; - line-height: 16px; + line-height: 18px; } -.student-chat-messenger { +.student-chat-modal { + display: none; position: absolute; right: 0; - bottom: 58px; - width: min(520px, calc(100vw - 24px)); - height: min(400px, calc(100vh - 80px)); + bottom: 60px; + width: min(560px, calc(100vw - 32px)); + height: min(440px, calc(100vh - 100px)); 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; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.38); flex-direction: column; overflow: hidden; } -.student-chat-messenger-head { +.student-chat-modal.is-open { + display: flex; +} + +.student-chat-modal-head { display: flex; - justify-content: space-between; align-items: center; - padding: 0.55rem 0.7rem; + justify-content: space-between; + padding: 0.65rem 0.85rem; border-bottom: 1px solid var(--border-color); - font-size: 0.82rem; flex-shrink: 0; + font-size: 0.88rem; } .student-chat-close { border: none; - background: none; + background: transparent; color: var(--text-muted); - font-size: 1.1rem; + width: 30px; + height: 30px; + border-radius: 6px; cursor: pointer; + font-size: 1.25rem; + line-height: 1; } -.student-chat-messenger-body { - display: flex; +.student-chat-close:hover { + background: rgba(187, 33, 38, 0.12); + color: var(--accent); +} + +.student-chat-modal-body { flex: 1; min-height: 0; + display: flex; + overflow: hidden; } .student-chat-sidebar { - width: 150px; + width: 200px; 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; + overscroll-behavior: contain; + background: rgba(0, 0, 0, 0.06); } -.student-chat-conv-btn { +.student-chat-conv-row { width: 100%; text-align: left; border: none; background: transparent; - padding: 0.55rem 0.5rem; + padding: 0.7rem 0.75rem; cursor: pointer; border-bottom: 1px solid var(--border-color); color: var(--text-primary); } -.student-chat-conv-btn.active { +.student-chat-conv-row:hover { + background: rgba(255, 255, 255, 0.04); +} + +.student-chat-conv-row.active { background: rgba(187, 33, 38, 0.12); - border-left: 2px solid var(--accent); + border-left: 3px solid var(--accent); } .student-chat-conv-name { display: flex; justify-content: space-between; - font-size: 0.72rem; + align-items: center; + gap: 0.35rem; + font-size: 0.78rem; font-weight: 600; } @@ -621,29 +642,32 @@ body { border-radius: 8px; padding: 0 5px; font-size: 0.6rem; + font-weight: 700; + flex-shrink: 0; } .student-chat-conv-preview { display: block; - font-size: 0.65rem; + font-size: 0.7rem; color: var(--text-muted); + margin-top: 0.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - margin-top: 0.15rem; } .student-chat-thread { flex: 1; + min-width: 0; display: flex; flex-direction: column; - min-width: 0; + overflow: hidden; } .student-chat-thread-head { - padding: 0.45rem 0.65rem; + padding: 0.55rem 0.75rem; border-bottom: 1px solid var(--border-color); - font-size: 0.75rem; + font-size: 0.8rem; font-weight: 600; flex-shrink: 0; } @@ -651,43 +675,38 @@ body { .student-chat-messages { flex: 1; overflow-y: auto; - padding: 0.55rem; + overscroll-behavior: contain; + padding: 0.65rem 0.75rem; display: flex; flex-direction: column; gap: 0.4rem; - background: rgba(0, 0, 0, 0.12); + background: rgba(0, 0, 0, 0.06); } .student-chat-empty { - font-size: 0.72rem; + font-size: 0.78rem; color: var(--text-muted); text-align: center; - padding: 1rem 0.5rem; + padding: 1.5rem 0.75rem; } .student-chat-bubble { - max-width: 88%; - font-size: 0.76rem; + max-width: 82%; + font-size: 0.8rem; } .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; + padding: 0.45rem 0.6rem; + border-radius: 12px; + line-height: 1.4; word-break: break-word; } .student-chat-bubble--staff .student-chat-body { - background: rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); } @@ -697,33 +716,31 @@ body { } .student-chat-time { - font-size: 0.58rem; + font-size: 0.6rem; color: var(--text-muted); - margin-top: 0.1rem; + margin-top: 0.12rem; + padding: 0 0.2rem; } .student-chat-compose { display: flex; - gap: 0.35rem; - padding: 0.45rem 0.55rem; + gap: 0.45rem; + padding: 0.55rem 0.7rem; border-top: 1px solid var(--border-color); flex-shrink: 0; + background: var(--bg-card); } .student-chat-compose input { flex: 1; - font-size: 0.74rem; - padding: 0.4rem 0.5rem; + font-size: 0.8rem; + padding: 0.5rem 0.6rem; 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 { flex: 1; min-height: 0; diff --git a/client/frontend/src/main.js b/client/frontend/src/main.js index 0a69052..8f64d28 100644 --- a/client/frontend/src/main.js +++ b/client/frontend/src/main.js @@ -27,34 +27,8 @@ 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 */ - } -} +let lastRenderedMsgCount = 0; +let bannerStaffId = 0; // Quản lý webcam let webcamStream = null; @@ -82,15 +56,14 @@ function init() { window.runtime.EventsOn('chat:message', (data) => { updateChatBadge(); loadChatConversations().catch(console.error); - if (chatOpen && activeStaffId) loadChatMessages(activeStaffId).catch(console.error); - if (data?.senderRole === 'staff') { - playChatSound(); + if (chatOpen && activeStaffId) { + loadChatMessages(activeStaffId, true).catch(console.error); } }); window.runtime.EventsOn('chat:notify', (data) => { updateChatBadge(); - showChatToast(data); - playChatSound(); + if (data?.staffId) bannerStaffId = Number(data.staffId); + pulseChatFab(); }); checkLogin(); } @@ -310,71 +283,85 @@ function ensureChatWidget() { wrap.id = 'student-chat-widget'; wrap.className = 'student-chat-dock'; wrap.innerHTML = ` -
-