Files
hfb_sys/frontend/src/features/chats/composables/useDesktopNotification.ts
T

213 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import type { ChatEvent } from './useChatSSE'
import { adminPath } from '@/shared/utils/adminPath'
import { debugError } from '@/shared/utils/debug'
export type NotificationPermission = 'default' | 'granted' | 'denied'
const permission = ref<NotificationPermission>('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<NotificationPermission> {
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<string, string> = {
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'
? adminPath(`chats/${event.conversation_id}`)
: `/messages/${event.conversation_id}`
router.push(path)
notification.close()
}
// 5秒后自动关闭
setTimeout(() => {
notification.close()
}, 5000)
} catch (error) {
debugError('桌面通知发送失败', 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) {
debugError('语音提醒播放失败', error)
}
}