增加客服消息声音与浏览器通知
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user