import { ref } from 'vue' import { useRouter } from 'vue-router' import type { ChatEvent } from './useChatSSE' export type NotificationPermission = 'default' | 'granted' | 'denied' const permission = ref('default') const isSupported = 'Notification' in window const isSpeechSupported = 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window const voiceEnabled = { user: ref(readVoiceEnabled('user')), admin: ref(readVoiceEnabled('admin')), } let lastVoiceAt = 0 /** * 桌面通知 composable * 用于在收到新消息时发送系统级通知 */ export function useDesktopNotification(scope: 'user' | 'admin') { const router = useRouter() // 初始化权限状态 if (isSupported) { permission.value = Notification.permission as NotificationPermission } /** * 请求通知权限 */ async function requestPermission(): Promise { if (!isSupported) return 'denied' try { const result = await Notification.requestPermission() permission.value = result as NotificationPermission return permission.value } catch { permission.value = 'denied' return 'denied' } } /** * 检查是否应该发送通知 */ function shouldAlert(conversationId: number, currentConversationId: number | null): boolean { // 页面在前台且正在查看该会话 - 不需要通知 if (!document.hidden && currentConversationId === conversationId) return false return true } /** * 检查是否应该发送桌面通知 */ function shouldNotify(conversationId: number, currentConversationId: number | null): boolean { // 不支持通知 if (!isSupported || permission.value !== 'granted') return false return shouldAlert(conversationId, currentConversationId) } /** * 发送桌面通知 */ function notify(event: ChatEvent, currentConversationId: number | null) { if (!event.message) return const { sender_name, sender_role, content, attachment_urls } = event.message // 构建通知内容 let body = content if (!body && attachment_urls.length > 0) { body = `[图片消息]` } if (!body) { body = '收到新消息' } // 角色标签 const roleMap: Record = { renter: '租客', owner: '号主', support: '客服', customer: '咨询', admin: '管理员', system: '系统', } const roleLabel = roleMap[sender_role] || '成员' const title = `${roleLabel} · ${sender_name}` if (!shouldAlert(event.conversation_id, currentConversationId)) return if (shouldNotify(event.conversation_id, currentConversationId)) { try { const notification = new Notification(title, { body: body.length > 100 ? body.substring(0, 100) + '...' : body, icon: '/favicon.ico', badge: '/favicon.ico', tag: `chat-${event.conversation_id}`, // 相同会话的通知会被替换 requireInteraction: false, silent: false, }) // 点击通知跳转到对应会话 notification.onclick = () => { window.focus() const path = scope === 'admin' ? `/admin/chats/${event.conversation_id}` : `/messages/${event.conversation_id}` router.push(path) notification.close() } // 5秒后自动关闭 setTimeout(() => { notification.close() }, 5000) } catch (error) { console.error('Failed to show notification:', error) } } speakMessage(roleLabel, sender_name, body) } /** * 静默请求权限(不强制,用户可以忽略) */ function requestPermissionSilently() { if (!isSupported || permission.value !== 'default') return // 仅在用户交互后才请求(浏览器要求) const handleInteraction = () => { requestPermission() document.removeEventListener('click', handleInteraction) document.removeEventListener('keydown', handleInteraction) } document.addEventListener('click', handleInteraction, { once: true }) document.addEventListener('keydown', handleInteraction, { once: true }) } function setVoiceEnabled(enabled: boolean) { if (!isSpeechSupported) return voiceEnabled[scope].value = enabled localStorage.setItem(voiceStorageKey(scope), enabled ? '1' : '0') if (enabled) speakText('语音提醒已开启') } function speakTest() { speakText('这是一条语音提醒测试') } function speakMessage(roleLabel: string, senderName: string, body: string) { if (!isSpeechSupported || !voiceEnabled[scope].value) return const now = Date.now() if (now - lastVoiceAt < 1200) return lastVoiceAt = now const message = sanitizeSpeechText(body) const prefix = scope === 'admin' ? '后台收到新消息' : '收到新消息' speakText(`${prefix},${roleLabel}${senderName}${message ? `说,${message}` : ''}`) } return { permission, isSupported, isSpeechSupported, voiceEnabled: voiceEnabled[scope], requestPermission, notify, requestPermissionSilently, setVoiceEnabled, speakTest, } } function voiceStorageKey(scope: 'user' | 'admin') { return `hfb.${scope}.notification.voice` } function readVoiceEnabled(scope: 'user' | 'admin') { if (typeof localStorage === 'undefined') return false return localStorage.getItem(voiceStorageKey(scope)) === '1' } function sanitizeSpeechText(value: string) { return value .replace(/https?:\/\/\\S+/g, '链接') .replace(/\\s+/g, ' ') .trim() .slice(0, 60) } function speakText(text: string) { if (!isSpeechSupported || !text) return try { window.speechSynthesis.cancel() const utterance = new SpeechSynthesisUtterance(text) utterance.lang = 'zh-CN' utterance.rate = 1 utterance.pitch = 1 utterance.volume = 1 window.speechSynthesis.speak(utterance) } catch (error) { console.error('Failed to speak notification:', error) } }