213 lines
6.1 KiB
TypeScript
213 lines
6.1 KiB
TypeScript
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)
|
||
}
|
||
}
|