增加客服消息声音与浏览器通知

This commit is contained in:
yml2213
2026-08-31 14:15:16 +08:00
parent bdf67395ab
commit 2b57bdc0c8
5 changed files with 312 additions and 11 deletions
+8 -5
View File
@@ -1,14 +1,17 @@
import { Outlet } from 'react-router-dom'
import AgentSidebar from './AgentSidebar'
import { AgentNotificationProvider } from '@/stores/notifications'
const AgentLayout = () => {
return (
<div className="flex h-screen overflow-hidden bg-neutral-50">
<AgentSidebar />
<div className="flex-1 min-w-0 h-full overflow-hidden">
<Outlet />
<AgentNotificationProvider>
<div className="flex h-screen overflow-hidden bg-neutral-50">
<AgentSidebar />
<div className="flex-1 min-w-0 h-full overflow-hidden">
<Outlet />
</div>
</div>
</div>
</AgentNotificationProvider>
)
}
+58 -4
View File
@@ -3,10 +3,11 @@ import { useLocation, useNavigate } from 'react-router-dom'
import {
AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined,
FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined,
ThunderboltOutlined, DownOutlined, StopOutlined, SafetyOutlined,
ThunderboltOutlined, DownOutlined, StopOutlined, SafetyOutlined, BellOutlined, BellFilled,
} from '@ant-design/icons'
import { Dropdown, message as antMsg } from 'antd'
import { Button, Dropdown, message as antMsg, Popover, Switch } from 'antd'
import { useAuth } from '@/stores/auth'
import { useAgentNotifications } from '@/stores/notifications'
import type { AgentPresence } from '@/services/api'
const menuItems = [
@@ -36,7 +37,15 @@ const AgentSidebar = () => {
const location = useLocation()
const navigate = useNavigate()
const { user, permissions, logout, setPresence } = useAuth()
const [switching, setSwitching] = useState(false)
const {
soundEnabled,
desktopEnabled,
permission,
setSoundEnabled,
setDesktopEnabled,
requestDesktopPermission,
} = useAgentNotifications()
const [switching, setSwitching] = useState(false)
const visibleMenuItems = menuItems.filter(item => item.permissions.some(code => permissions.has(code)))
@@ -62,7 +71,41 @@ const AgentSidebar = () => {
}
const initial = (user?.nickname || '客').slice(0, 1)
const current = presenceMeta(user?.status || 'online')
const current = presenceMeta(user?.status || 'online')
const notificationContent = (
<div className="w-64 space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm text-neutral-800">声音提醒</div>
<div className="text-xs text-neutral-400">收到访客消息时播放提示音</div>
</div>
<Switch size="small" checked={soundEnabled} onChange={setSoundEnabled} />
</div>
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm text-neutral-800">浏览器通知</div>
<div className="text-xs text-neutral-400">
{permission === 'denied' ? '已被浏览器禁止' : permission === 'unsupported' ? '当前浏览器不支持' : '在后台弹出新消息'}
</div>
</div>
<Switch
size="small"
checked={desktopEnabled && permission === 'granted'}
disabled={permission === 'denied' || permission === 'unsupported'}
onChange={enabled => {
if (enabled) void requestDesktopPermission()
else setDesktopEnabled(false)
}}
/>
</div>
{permission === 'default' && (
<Button type="link" size="small" className="px-0" onClick={() => { void requestDesktopPermission() }}>
允许浏览器通知
</Button>
)}
</div>
)
return (
<aside
@@ -77,6 +120,17 @@ const AgentSidebar = () => {
<MessageOutlined className="text-white text-sm" />
</div>
<span className="font-semibold text-neutral-900 text-base truncate">在线客服</span>
<Popover trigger="click" placement="bottomRight" content={notificationContent} title="消息提醒">
<button
type="button"
title="消息提醒设置"
className="ml-auto w-7 h-7 rounded-md text-neutral-400 hover:text-neutral-600 hover:bg-neutral-50 flex items-center justify-center shrink-0"
>
<span className="relative flex items-center justify-center">
{desktopEnabled && permission === 'granted' ? <BellFilled className="text-sm" /> : <BellOutlined className="text-sm" />}
</span>
</button>
</Popover>
</div>
<nav className="flex-1 overflow-y-auto py-3 px-2">
+14 -1
View File
@@ -10,6 +10,7 @@ import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
import { ChatImage } from '@/components/common/ImagePreview'
import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody'
import { useAuth } from '@/stores/auth'
import { OPEN_AGENT_SESSION_EVENT, useAgentNotifications } from '@/stores/notifications'
import {
addSessionNote, claimSession, createBlacklist, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
getCustomerTags, getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
@@ -390,6 +391,7 @@ function shortPagePath(url?: string): string {
const Dashboard = () => {
const { user } = useAuth()
const { setActiveSessionId } = useAgentNotifications()
const [sessions, setSessions] = useState<Session[]>([])
const [customers, setCustomers] = useState<Record<number, Customer>>({})
const [loading, setLoading] = useState(true)
@@ -665,7 +667,18 @@ const Dashboard = () => {
useEffect(() => {
selectedIdRef.current = selectedId
}, [selectedId])
setActiveSessionId(selectedId)
return () => setActiveSessionId(null)
}, [selectedId, setActiveSessionId])
useEffect(() => {
const handleOpenSession = (event: Event) => {
const sessionId = Number((event as CustomEvent<number>).detail)
if (Number.isFinite(sessionId) && sessionId > 0) setSelectedId(sessionId)
}
window.addEventListener(OPEN_AGENT_SESSION_EVENT, handleOpenSession)
return () => window.removeEventListener(OPEN_AGENT_SESSION_EVENT, handleOpenSession)
}, [])
useEffect(() => {
loadAllRef.current = loadAll
+1 -1
View File
@@ -1204,7 +1204,7 @@ const Settings = () => {
>
<div className="space-y-3 mb-5">
{[
{ name: 'notify_new_session', title: '新会话提醒', desc: '有新访客进线时提醒(推送通道后续接入)' },
{ name: 'notify_new_session', title: '新会话提醒', desc: '有新访客进线时提醒;浏览器通知与声音可在工作台铃铛中配置' },
{ name: 'notify_offline_leave', title: '离线留言通知', desc: '访客提交离线留言时提醒' },
{ name: 'notify_daily_report', title: '日报推送', desc: '每日服务数据摘要' },
].map(item => (
+231
View File
@@ -0,0 +1,231 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useAuth } from './auth'
const SOUND_KEY = 'kefu_notification_sound'
const DESKTOP_KEY = 'kefu_notification_desktop'
const OPEN_SESSION_EVENT = 'kefu-open-session'
interface IncomingMessage {
id?: number | string
session_id?: number | string
sender_type?: string
content?: string
type?: string
visitor_name?: string
}
interface NotificationState {
soundEnabled: boolean
desktopEnabled: boolean
permission: NotificationPermission | 'unsupported'
setSoundEnabled: (enabled: boolean) => void
setDesktopEnabled: (enabled: boolean) => void
requestDesktopPermission: () => Promise<boolean>
setActiveSessionId: (id: number | null) => void
}
const NotificationContext = createContext<NotificationState | null>(null)
function readBoolean(key: string, fallback: boolean): boolean {
try {
const value = localStorage.getItem(key)
return value == null ? fallback : value === 'true'
} catch {
return fallback
}
}
function messagePreview(message: IncomingMessage): string {
if (message.type === 'image') return '[图片]'
const content = String(message.content || '').replace(/\s+/g, ' ').trim()
return content.length > 80 ? `${content.slice(0, 80)}…` : content || '发来一条新消息'
}
function displayName(message: IncomingMessage): string {
const name = String(message.visitor_name || '').trim()
return name || '访客'
}
function getPermission(): NotificationPermission | 'unsupported' {
return typeof window !== 'undefined' && 'Notification' in window
? window.Notification.permission
: 'unsupported'
}
export function AgentNotificationProvider({ children }: { children: ReactNode }) {
const { user } = useAuth()
const [soundEnabled, setSoundEnabledState] = useState(() => readBoolean(SOUND_KEY, true))
const [desktopEnabled, setDesktopEnabledState] = useState(() => readBoolean(DESKTOP_KEY, true))
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(getPermission)
const activeSessionIdRef = useRef<number | null>(null)
const audioContextRef = useRef<AudioContext | null>(null)
const seenMessageIdsRef = useRef<Set<number>>(new Set())
const setSoundEnabled = useCallback((enabled: boolean) => {
setSoundEnabledState(enabled)
try { localStorage.setItem(SOUND_KEY, String(enabled)) } catch { /* storage unavailable */ }
}, [])
const setDesktopEnabled = useCallback((enabled: boolean) => {
setDesktopEnabledState(enabled)
try { localStorage.setItem(DESKTOP_KEY, String(enabled)) } catch { /* storage unavailable */ }
}, [])
const ensureAudioContext = useCallback(() => {
if (!soundEnabled || typeof window === 'undefined') return null
const AudioContextCtor = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!AudioContextCtor) return null
if (!audioContextRef.current) audioContextRef.current = new AudioContextCtor()
return audioContextRef.current
}, [soundEnabled])
const unlockAudio = useCallback(() => {
const context = ensureAudioContext()
if (context?.state === 'suspended') void context.resume()
}, [ensureAudioContext])
const playSound = useCallback(() => {
const context = ensureAudioContext()
if (!context) return
const start = context.currentTime
const oscillator = context.createOscillator()
const gain = context.createGain()
oscillator.type = 'sine'
oscillator.frequency.setValueAtTime(880, start)
oscillator.frequency.setValueAtTime(660, start + 0.11)
gain.gain.setValueAtTime(0.0001, start)
gain.gain.exponentialRampToValueAtTime(0.12, start + 0.015)
gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.24)
oscillator.connect(gain)
gain.connect(context.destination)
oscillator.start(start)
oscillator.stop(start + 0.25)
}, [ensureAudioContext])
useEffect(() => {
window.addEventListener('pointerdown', unlockAudio)
window.addEventListener('keydown', unlockAudio)
return () => {
window.removeEventListener('pointerdown', unlockAudio)
window.removeEventListener('keydown', unlockAudio)
}
}, [unlockAudio])
useEffect(() => () => {
void audioContextRef.current?.close()
audioContextRef.current = null
}, [])
const requestDesktopPermission = useCallback(async () => {
if (!('Notification' in window)) {
setPermission('unsupported')
setDesktopEnabled(false)
return false
}
const result = await window.Notification.requestPermission()
setPermission(result)
const enabled = result === 'granted'
setDesktopEnabled(enabled)
return enabled
}, [setDesktopEnabled])
const setActiveSessionId = useCallback((id: number | null) => {
activeSessionIdRef.current = id
}, [])
useEffect(() => {
if (!user?.token) return
let disposed = false
let reconnectTimer: number | null = null
let attempt = 0
let activeSocket: WebSocket | null = null
const clearReconnect = () => {
if (reconnectTimer != null) {
window.clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
const showNotification = (message: IncomingMessage, sessionId: number) => {
const body = `${displayName(message)}:${messagePreview(message)}`
if (soundEnabled) playSound()
if (!desktopEnabled || permission !== 'granted') return
const notification = new window.Notification('客服云:新消息', {
body,
tag: `kefu-session-${sessionId}`,
})
notification.onclick = () => {
window.focus()
window.dispatchEvent(new CustomEvent(OPEN_SESSION_EVENT, { detail: sessionId }))
notification.close()
}
}
const connect = () => {
if (disposed) return
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const socket = new WebSocket(`${scheme}//${window.location.host}/api/ws`, ['kefu-v1', user.token])
activeSocket = socket
socket.onopen = () => { attempt = 0 }
socket.onmessage = event => {
try {
const payload = JSON.parse(event.data) as { type?: string; session_id?: number | string; data?: IncomingMessage }
if (payload.type !== 'message' || payload.data?.sender_type !== 'visitor') return
const sessionId = Number(payload.session_id ?? payload.data.session_id)
const messageId = Number(payload.data.id)
if (!Number.isFinite(sessionId) || !Number.isFinite(messageId)) return
if (seenMessageIdsRef.current.has(messageId)) return
seenMessageIdsRef.current.add(messageId)
if (seenMessageIdsRef.current.size > 500) {
const first = seenMessageIdsRef.current.values().next().value
if (typeof first === 'number') seenMessageIdsRef.current.delete(first)
}
if (activeSessionIdRef.current === sessionId && document.visibilityState === 'visible') return
showNotification(payload.data, sessionId)
} catch {
// Ignore malformed WebSocket frames.
}
}
socket.onclose = () => {
if (activeSocket === socket) activeSocket = null
if (disposed) return
const delay = Math.min(1000 * (2 ** attempt), 15000)
attempt += 1
reconnectTimer = window.setTimeout(connect, delay)
}
socket.onerror = () => { try { socket.close() } catch { /* ignore */ } }
}
connect()
return () => {
disposed = true
clearReconnect()
if (activeSocket) {
activeSocket.onclose = null
activeSocket.close()
activeSocket = null
}
}
}, [desktopEnabled, permission, playSound, soundEnabled, user?.token])
const value = useMemo(() => ({
soundEnabled,
desktopEnabled,
permission,
setSoundEnabled,
setDesktopEnabled,
requestDesktopPermission,
setActiveSessionId,
}), [desktopEnabled, permission, requestDesktopPermission, setActiveSessionId, setDesktopEnabled, setSoundEnabled, soundEnabled])
return <NotificationContext.Provider value={value}>{children}</NotificationContext.Provider>
}
export function useAgentNotifications(): NotificationState {
const context = useContext(NotificationContext)
if (!context) throw new Error('useAgentNotifications 必须在 AgentNotificationProvider 内使用')
return context
}
export const OPEN_AGENT_SESSION_EVENT = OPEN_SESSION_EVENT