= {
+ 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,
+ }
+}
diff --git a/frontend/src/features/chats/views/ChatView.vue b/frontend/src/features/chats/views/ChatView.vue
index f5e90ab..283cfab 100644
--- a/frontend/src/features/chats/views/ChatView.vue
+++ b/frontend/src/features/chats/views/ChatView.vue
@@ -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()
})
diff --git a/frontend/src/features/chats/views/MessagesView.vue b/frontend/src/features/chats/views/MessagesView.vue
index 349bfe7..fb07976 100644
--- a/frontend/src/features/chats/views/MessagesView.vue
+++ b/frontend/src/features/chats/views/MessagesView.vue
@@ -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) {
{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊和平台客服消息。' }}
diff --git a/frontend/src/features/chats/views/MobileChatView.vue b/frontend/src/features/chats/views/MobileChatView.vue
index dcf8a22..5cab1e0 100644
--- a/frontend/src/features/chats/views/MobileChatView.vue
+++ b/frontend/src/features/chats/views/MobileChatView.vue
@@ -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()
})