diff --git a/DESKTOP_NOTIFICATION.md b/DESKTOP_NOTIFICATION.md new file mode 100644 index 0000000..ab6c1f1 --- /dev/null +++ b/DESKTOP_NOTIFICATION.md @@ -0,0 +1,112 @@ +# 桌面通知功能说明 + +## 功能概述 + +系统已集成浏览器桌面通知功能,当收到新消息时会在 Windows/Mac 系统级别发送通知。 + +## 触发条件 + +桌面通知会在以下情况触发: +1. 收到新消息(非自己发送的) +2. 页面不在前台(浏览器最小化或切换到其他标签页) +3. 或者正在查看其他会话(不在当前消息的会话页面) + +## 通知内容 + +- **标题**:发送者角色 + 昵称(如:"客服 · 张三") +- **内容**:消息文本(最多显示100字) +- **图片消息**:显示 "[图片消息]" +- **点击通知**:自动跳转到对应会话 + +## 使用方式 + +### 用户端 +1. 进入 `/messages` 消息列表页面 +2. 点击页面右上角的铃铛图标 +3. 浏览器会弹出权限请求,点击"允许" +4. 之后收到新消息时会自动发送桌面通知 + +### 管理端 +1. 进入 `/admin/chats` 客服管理页面 +2. 首次进入会在用户交互后自动请求权限 +3. 允许权限后即可接收通知 + +### 移动端 +- iOS Safari:不支持桌面通知 +- Android Chrome:支持桌面通知 + +## 权限状态 + +- **未开启**:默认状态,首次需要点击允许 +- **已开启**:绿色铃铛图标,正常接收通知 +- **已拒绝**:用户点击了拒绝,需要在浏览器设置中手动开启 + - Chrome: 设置 → 隐私和安全 → 网站设置 → 通知 + - Safari: 偏好设置 → 网站 → 通知 + +## 技术实现 + +### 前端文件 +- `frontend/src/features/chats/composables/useDesktopNotification.ts` - 通知核心逻辑 +- `frontend/src/features/chats/components/NotificationSettings.vue` - 通知设置组件 +- `frontend/src/features/chats/views/ChatView.vue` - 用户端聊天页集成 +- `frontend/src/features/chats/views/MobileChatView.vue` - 移动端聊天页集成 +- `frontend/src/features/admin/views/AdminChatsView.vue` - 管理端集成 + +### 核心 API +```typescript +const { notify, requestPermission, permission } = useDesktopNotification('user') + +// 发送通知 +notify(chatEvent, currentConversationId) + +// 请求权限 +await requestPermission() + +// 权限状态:'default' | 'granted' | 'denied' +console.log(permission.value) +``` + +## 浏览器兼容性 + +| 浏览器 | 桌面通知支持 | +|--------|-------------| +| Chrome | ✅ 完全支持 | +| Firefox | ✅ 完全支持 | +| Safari (macOS) | ✅ 完全支持 | +| Edge | ✅ 完全支持 | +| Safari (iOS) | ❌ 不支持 | +| 微信内置浏览器 | ❌ 不支持 | + +## 通知特性 + +1. **智能去重**:相同会话的通知会自动替换(不会堆积) +2. **自动关闭**:通知5秒后自动消失 +3. **点击跳转**:点击通知会自动跳转到对应会话并聚焦浏览器窗口 +4. **不干扰当前会话**:如果正在查看该会话,不会发送通知 +5. **仅限他人消息**:自己发送的消息不会触发通知 + +## 注意事项 + +1. **首次使用需要用户授权**:浏览器安全策略要求用户主动允许 +2. **需要 HTTPS**:生产环境必须使用 HTTPS(本地开发 localhost 除外) +3. **系统勿扰模式**:如果操作系统开启了勿扰模式,通知可能不会显示 +4. **浏览器设置**:用户可以在浏览器设置中随时关闭网站通知 + +## 测试方法 + +1. 打开两个浏览器窗口(或使用隐私模式) +2. 窗口A:登录用户/客服A,进入某个会话 +3. 窗口B:登录用户/客服B,进入相同会话 +4. 在窗口B发送消息 +5. 将窗口A最小化或切换到其他标签页 +6. 应该会收到系统通知 + +## 排查问题 + +如果没有收到通知,检查: +1. 浏览器是否支持 Notification API +2. 网站通知权限是否被拒绝(检查浏览器地址栏左侧的锁图标) +3. 操作系统通知设置是否开启 +4. 浏览器是否开启了勿扰模式 +5. 是否在查看当前会话(查看当前会话不会发通知) +6. 是否是自己发送的消息(自己发送的不会通知) diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 72bc2f2..b74fdf4 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -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'] diff --git a/frontend/src/features/admin/views/AdminChatsView.vue b/frontend/src/features/admin/views/AdminChatsView.vue index a85636d..ed42410 100644 --- a/frontend/src/features/admin/views/AdminChatsView.vue +++ b/frontend/src/features/admin/views/AdminChatsView.vue @@ -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()]) }) diff --git a/frontend/src/features/chats/components/NotificationSettings.vue b/frontend/src/features/chats/components/NotificationSettings.vue new file mode 100644 index 0000000..762a30a --- /dev/null +++ b/frontend/src/features/chats/components/NotificationSettings.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frontend/src/features/chats/composables/useDesktopNotification.ts b/frontend/src/features/chats/composables/useDesktopNotification.ts new file mode 100644 index 0000000..3f83870 --- /dev/null +++ b/frontend/src/features/chats/composables/useDesktopNotification.ts @@ -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('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 { + 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 = { + 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() })