桌面通知(系统级消息提醒

This commit is contained in:
yml
2026-06-05 20:06:50 +08:00
parent e32362f425
commit bdd1ba6052
8 changed files with 383 additions and 17 deletions
+2
View File
@@ -14,6 +14,7 @@ declare module 'vue' {
ChatAttachmentImage: typeof import('./src/components/ChatAttachmentImage.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
@@ -50,6 +51,7 @@ declare module 'vue' {
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
MobileBottomNav: typeof import('./src/components/MobileBottomNav.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
@@ -17,6 +17,7 @@ import {
import { uploadAdminFile } from '@/shared/api/files'
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
import { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
import { formatDateMinute } from '@/utils/time'
import TransferDialog from '../components/TransferDialog.vue'
import QuickReplyDialog from '../components/QuickReplyDialog.vue'
@@ -51,6 +52,9 @@ const activeMembers = computed(() => {
})
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
// 桌面通知
const desktopNotification = useDesktopNotification('admin')
function getParticipantRemark(participant: any) {
if (!active.value) return ''
const myParticipant = active.value.participants?.find(
@@ -66,6 +70,8 @@ function handleSSEEvent(event: ChatEvent) {
if (event.type === 'new_message' && active.value && event.conversation_id === active.value.id) {
const msg = event.message
if (msg && !messages.value.some(m => m.id === msg.id)) {
const isSelf = msg.sender_type === 'admin' && msg.sender_id === currentAdminId
messages.value = [...messages.value, {
id: msg.id,
conversation_id: msg.conversation_id,
@@ -74,13 +80,18 @@ function handleSSEEvent(event: ChatEvent) {
sender_role: msg.sender_role as ChatMessage['sender_role'],
sender_name: msg.sender_name,
sender_avatar: '',
is_self: msg.sender_type === 'admin' && msg.sender_id === currentAdminId,
is_self: isSelf,
content_type: msg.content_type as ChatMessage['content_type'],
content: msg.content,
attachment_urls: msg.attachment_urls || [],
created_at: msg.created_at,
}]
nextTick(() => scrollBottom())
// 不是自己发送的消息才发送通知
if (!isSelf) {
desktopNotification.notify(event, active.value.id)
}
}
}
}
@@ -89,6 +100,8 @@ const { onEvent } = useChatSSE('admin', '/api/admin/chats/events')
onEvent(handleSSEEvent)
onMounted(async () => {
// 静默请求通知权限(用户交互后才会弹窗)
desktopNotification.requestPermissionSilently()
await Promise.all([loadConversations(), loadQuickReplies()])
})
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Bell, BellFilled } from '@element-plus/icons-vue'
import { useDesktopNotification } from '../composables/useDesktopNotification'
const props = defineProps<{
scope: 'user' | 'admin'
}>()
const notification = useDesktopNotification(props.scope)
const statusText = computed(() => {
if (!notification.isSupported) return '不支持'
if (notification.permission.value === 'granted') return '已开启'
if (notification.permission.value === 'denied') return '已拒绝'
return '未开启'
})
const statusType = computed(() => {
if (notification.permission.value === 'granted') return 'success'
if (notification.permission.value === 'denied') return 'danger'
return 'info'
})
async function toggleNotification() {
if (!notification.isSupported) return
if (notification.permission.value === 'default') {
await notification.requestPermission()
} else if (notification.permission.value === 'denied') {
// 用户已拒绝,需要在浏览器设置中手动开启
alert('通知权限已被拒绝,请在浏览器设置中手动开启')
}
}
</script>
<template>
<div class="notification-settings">
<el-tooltip
content="接收新消息的桌面通知"
placement="bottom"
>
<el-button
:icon="notification.permission.value === 'granted' ? BellFilled : Bell"
:type="notification.permission.value === 'granted' ? 'primary' : 'default'"
:disabled="!notification.isSupported || notification.permission.value === 'denied'"
circle
@click="toggleNotification"
>
<template #default>
<el-badge
v-if="notification.permission.value !== 'granted' && notification.isSupported"
is-dot
:type="statusType"
/>
</template>
</el-button>
</el-tooltip>
<span class="status-text">{{ statusText }}</span>
</div>
</template>
<style scoped>
.notification-settings {
display: inline-flex;
align-items: center;
gap: 8px;
}
.status-text {
color: #8a94a6;
font-size: 12px;
}
</style>
@@ -0,0 +1,134 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import type { ChatEvent } from './useChatSSE'
export type NotificationPermission = 'default' | 'granted' | 'denied'
const permission = ref<NotificationPermission>('default')
const isSupported = 'Notification' in window
/**
* 桌面通知 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 shouldNotify(conversationId: number, currentConversationId: number | null): boolean {
// 不支持通知
if (!isSupported || permission.value !== 'granted') return false
// 页面在前台且正在查看该会话 - 不需要通知
if (!document.hidden && currentConversationId === conversationId) return false
return true
}
/**
* 发送桌面通知
*/
function notify(event: ChatEvent, currentConversationId: number | null) {
if (!event.message) return
if (!shouldNotify(event.conversation_id, currentConversationId)) 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}`
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)
}
}
/**
* 静默请求权限(不强制,用户可以忽略)
*/
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 })
}
return {
permission,
isSupported,
requestPermission,
notify,
requestPermissionSilently,
}
}
+31 -15
View File
@@ -14,6 +14,7 @@ import {
import { uploadFile } from '@/shared/api/files'
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
import { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
import { formatDateMinute } from '@/utils/time'
const currentUserId = Number(localStorage.getItem('user_id') || 0)
@@ -38,24 +39,37 @@ const memberText = computed(() => {
return participants.map(item => `${roleLabel(item.role)}${item.display_name}`).join(' / ')
})
// 桌面通知
const desktopNotification = useDesktopNotification('user')
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'new_message' && event.conversation_id === conversationID.value) {
const msg = event.message
if (msg) appendMessage({
id: msg.id,
conversation_id: msg.conversation_id,
sender_type: msg.sender_type as ChatMessage['sender_type'],
sender_id: msg.sender_id,
sender_role: msg.sender_role as ChatMessage['sender_role'],
sender_name: msg.sender_name,
sender_avatar: '',
is_self: msg.sender_type === 'user' && msg.sender_id === currentUserId,
content_type: msg.content_type as ChatMessage['content_type'],
content: msg.content,
attachment_urls: msg.attachment_urls || [],
created_at: msg.created_at,
})
markChatRead(conversationID.value).catch(() => {})
if (msg) {
const isSelf = msg.sender_type === 'user' && msg.sender_id === currentUserId
appendMessage({
id: msg.id,
conversation_id: msg.conversation_id,
sender_type: msg.sender_type as ChatMessage['sender_type'],
sender_id: msg.sender_id,
sender_role: msg.sender_role as ChatMessage['sender_role'],
sender_name: msg.sender_name,
sender_avatar: '',
is_self: isSelf,
content_type: msg.content_type as ChatMessage['content_type'],
content: msg.content,
attachment_urls: msg.attachment_urls || [],
created_at: msg.created_at,
})
markChatRead(conversationID.value).catch(() => {})
// 不是自己发送的消息才发送通知
if (!isSelf) {
desktopNotification.notify(event, conversationID.value)
}
}
}
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
loadConversation()
@@ -66,6 +80,8 @@ const { onEvent } = useChatSSE('user', '/api/chats/events')
onEvent(handleSSEEvent)
onMounted(async () => {
// 静默请求通知权限(用户交互后才会弹窗)
desktopNotification.requestPermissionSilently()
await loadAll()
})
@@ -5,6 +5,7 @@ import { ElMessage } from 'element-plus'
import { ChatDotRound, Refresh, Tickets } from '@element-plus/icons-vue'
import { fetchChats, type ChatConversation } from '@/features/chats/api/chats'
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
import NotificationSettings from '@/features/chats/components/NotificationSettings.vue'
import { formatDateMinute } from '@/utils/time'
const router = useRouter()
@@ -69,6 +70,7 @@ function previewText(item: ChatConversation) {
<p>{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊和平台客服消息。' }}</p>
</div>
<div class="header-actions">
<NotificationSettings scope="user" />
<el-button :icon="Refresh" :loading="loading" @click="loadChats(true)">刷新</el-button>
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">我的订单</el-button>
</div>
@@ -13,6 +13,7 @@ import {
import { uploadFile } from '@/shared/api/files'
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
import { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
import { formatDateMinute } from '@/utils/time'
const currentUserId = Number(localStorage.getItem('user_id') || 0)
@@ -37,10 +38,15 @@ const memberText = computed(() => {
return participants.map(item => `${roleLabel(item.role)}${item.display_name}`).join(' / ')
})
// 桌面通知
const desktopNotification = useDesktopNotification('user')
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'new_message' && event.conversation_id === conversationID.value) {
const msg = event.message
if (msg && !messages.value.some(m => m.id === msg.id)) {
const isSelf = msg.sender_type === 'user' && msg.sender_id === currentUserId
messages.value = [...messages.value, {
id: msg.id,
conversation_id: msg.conversation_id,
@@ -49,13 +55,18 @@ function handleSSEEvent(event: ChatEvent) {
sender_role: msg.sender_role as ChatMessage['sender_role'],
sender_name: msg.sender_name,
sender_avatar: '',
is_self: msg.sender_type === 'user' && msg.sender_id === currentUserId,
is_self: isSelf,
content_type: msg.content_type as ChatMessage['content_type'],
content: msg.content,
attachment_urls: msg.attachment_urls || [],
created_at: msg.created_at,
}]
nextTick(() => scrollBottom())
// 不是自己发送的消息才发送通知
if (!isSelf) {
desktopNotification.notify(event, conversationID.value)
}
}
}
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
@@ -67,6 +78,8 @@ const { onEvent } = useChatSSE('user', '/api/chats/events')
onEvent(handleSSEEvent)
onMounted(async () => {
// 静默请求通知权限(用户交互后才会弹窗)
desktopNotification.requestPermissionSilently()
await loadAll()
})