桌面通知(系统级消息提醒
This commit is contained in:
@@ -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. 是否是自己发送的消息(自己发送的不会通知)
|
||||||
Vendored
+2
@@ -14,6 +14,7 @@ declare module 'vue' {
|
|||||||
ChatAttachmentImage: typeof import('./src/components/ChatAttachmentImage.vue')['default']
|
ChatAttachmentImage: typeof import('./src/components/ChatAttachmentImage.vue')['default']
|
||||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||||
|
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
@@ -50,6 +51,7 @@ declare module 'vue' {
|
|||||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||||
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
|
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
|
||||||
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
MobileBottomNav: typeof import('./src/components/MobileBottomNav.vue')['default']
|
MobileBottomNav: typeof import('./src/components/MobileBottomNav.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { uploadAdminFile } from '@/shared/api/files'
|
import { uploadAdminFile } from '@/shared/api/files'
|
||||||
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
|
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
|
||||||
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
||||||
|
import { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
|
||||||
import { formatDateMinute } from '@/utils/time'
|
import { formatDateMinute } from '@/utils/time'
|
||||||
import TransferDialog from '../components/TransferDialog.vue'
|
import TransferDialog from '../components/TransferDialog.vue'
|
||||||
import QuickReplyDialog from '../components/QuickReplyDialog.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 canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
|
||||||
|
|
||||||
|
// 桌面通知
|
||||||
|
const desktopNotification = useDesktopNotification('admin')
|
||||||
|
|
||||||
function getParticipantRemark(participant: any) {
|
function getParticipantRemark(participant: any) {
|
||||||
if (!active.value) return ''
|
if (!active.value) return ''
|
||||||
const myParticipant = active.value.participants?.find(
|
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) {
|
if (event.type === 'new_message' && active.value && event.conversation_id === active.value.id) {
|
||||||
const msg = event.message
|
const msg = event.message
|
||||||
if (msg && !messages.value.some(m => m.id === msg.id)) {
|
if (msg && !messages.value.some(m => m.id === msg.id)) {
|
||||||
|
const isSelf = msg.sender_type === 'admin' && msg.sender_id === currentAdminId
|
||||||
|
|
||||||
messages.value = [...messages.value, {
|
messages.value = [...messages.value, {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
conversation_id: msg.conversation_id,
|
conversation_id: msg.conversation_id,
|
||||||
@@ -74,13 +80,18 @@ function handleSSEEvent(event: ChatEvent) {
|
|||||||
sender_role: msg.sender_role as ChatMessage['sender_role'],
|
sender_role: msg.sender_role as ChatMessage['sender_role'],
|
||||||
sender_name: msg.sender_name,
|
sender_name: msg.sender_name,
|
||||||
sender_avatar: '',
|
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_type: msg.content_type as ChatMessage['content_type'],
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
attachment_urls: msg.attachment_urls || [],
|
attachment_urls: msg.attachment_urls || [],
|
||||||
created_at: msg.created_at,
|
created_at: msg.created_at,
|
||||||
}]
|
}]
|
||||||
nextTick(() => scrollBottom())
|
nextTick(() => scrollBottom())
|
||||||
|
|
||||||
|
// 不是自己发送的消息才发送通知
|
||||||
|
if (!isSelf) {
|
||||||
|
desktopNotification.notify(event, active.value.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,6 +100,8 @@ const { onEvent } = useChatSSE('admin', '/api/admin/chats/events')
|
|||||||
onEvent(handleSSEEvent)
|
onEvent(handleSSEEvent)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// 静默请求通知权限(用户交互后才会弹窗)
|
||||||
|
desktopNotification.requestPermissionSilently()
|
||||||
await Promise.all([loadConversations(), loadQuickReplies()])
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { uploadFile } from '@/shared/api/files'
|
import { uploadFile } from '@/shared/api/files'
|
||||||
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
|
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
|
||||||
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
||||||
|
import { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
|
||||||
import { formatDateMinute } from '@/utils/time'
|
import { formatDateMinute } from '@/utils/time'
|
||||||
|
|
||||||
const currentUserId = Number(localStorage.getItem('user_id') || 0)
|
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(' / ')
|
return participants.map(item => `${roleLabel(item.role)}:${item.display_name}`).join(' / ')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 桌面通知
|
||||||
|
const desktopNotification = useDesktopNotification('user')
|
||||||
|
|
||||||
function handleSSEEvent(event: ChatEvent) {
|
function handleSSEEvent(event: ChatEvent) {
|
||||||
if (event.type === 'new_message' && event.conversation_id === conversationID.value) {
|
if (event.type === 'new_message' && event.conversation_id === conversationID.value) {
|
||||||
const msg = event.message
|
const msg = event.message
|
||||||
if (msg) appendMessage({
|
if (msg) {
|
||||||
id: msg.id,
|
const isSelf = msg.sender_type === 'user' && msg.sender_id === currentUserId
|
||||||
conversation_id: msg.conversation_id,
|
|
||||||
sender_type: msg.sender_type as ChatMessage['sender_type'],
|
appendMessage({
|
||||||
sender_id: msg.sender_id,
|
id: msg.id,
|
||||||
sender_role: msg.sender_role as ChatMessage['sender_role'],
|
conversation_id: msg.conversation_id,
|
||||||
sender_name: msg.sender_name,
|
sender_type: msg.sender_type as ChatMessage['sender_type'],
|
||||||
sender_avatar: '',
|
sender_id: msg.sender_id,
|
||||||
is_self: msg.sender_type === 'user' && msg.sender_id === currentUserId,
|
sender_role: msg.sender_role as ChatMessage['sender_role'],
|
||||||
content_type: msg.content_type as ChatMessage['content_type'],
|
sender_name: msg.sender_name,
|
||||||
content: msg.content,
|
sender_avatar: '',
|
||||||
attachment_urls: msg.attachment_urls || [],
|
is_self: isSelf,
|
||||||
created_at: msg.created_at,
|
content_type: msg.content_type as ChatMessage['content_type'],
|
||||||
})
|
content: msg.content,
|
||||||
markChatRead(conversationID.value).catch(() => {})
|
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) {
|
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
|
||||||
loadConversation()
|
loadConversation()
|
||||||
@@ -66,6 +80,8 @@ const { onEvent } = useChatSSE('user', '/api/chats/events')
|
|||||||
onEvent(handleSSEEvent)
|
onEvent(handleSSEEvent)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// 静默请求通知权限(用户交互后才会弹窗)
|
||||||
|
desktopNotification.requestPermissionSilently()
|
||||||
await loadAll()
|
await loadAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ElMessage } from 'element-plus'
|
|||||||
import { ChatDotRound, Refresh, Tickets } from '@element-plus/icons-vue'
|
import { ChatDotRound, Refresh, Tickets } from '@element-plus/icons-vue'
|
||||||
import { fetchChats, type ChatConversation } from '@/features/chats/api/chats'
|
import { fetchChats, type ChatConversation } from '@/features/chats/api/chats'
|
||||||
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
||||||
|
import NotificationSettings from '@/features/chats/components/NotificationSettings.vue'
|
||||||
import { formatDateMinute } from '@/utils/time'
|
import { formatDateMinute } from '@/utils/time'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -69,6 +70,7 @@ function previewText(item: ChatConversation) {
|
|||||||
<p>{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊和平台客服消息。' }}</p>
|
<p>{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊和平台客服消息。' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<NotificationSettings scope="user" />
|
||||||
<el-button :icon="Refresh" :loading="loading" @click="loadChats(true)">刷新</el-button>
|
<el-button :icon="Refresh" :loading="loading" @click="loadChats(true)">刷新</el-button>
|
||||||
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">我的订单</el-button>
|
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">我的订单</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { uploadFile } from '@/shared/api/files'
|
import { uploadFile } from '@/shared/api/files'
|
||||||
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
|
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
|
||||||
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
||||||
|
import { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
|
||||||
import { formatDateMinute } from '@/utils/time'
|
import { formatDateMinute } from '@/utils/time'
|
||||||
|
|
||||||
const currentUserId = Number(localStorage.getItem('user_id') || 0)
|
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(' / ')
|
return participants.map(item => `${roleLabel(item.role)}:${item.display_name}`).join(' / ')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 桌面通知
|
||||||
|
const desktopNotification = useDesktopNotification('user')
|
||||||
|
|
||||||
function handleSSEEvent(event: ChatEvent) {
|
function handleSSEEvent(event: ChatEvent) {
|
||||||
if (event.type === 'new_message' && event.conversation_id === conversationID.value) {
|
if (event.type === 'new_message' && event.conversation_id === conversationID.value) {
|
||||||
const msg = event.message
|
const msg = event.message
|
||||||
if (msg && !messages.value.some(m => m.id === msg.id)) {
|
if (msg && !messages.value.some(m => m.id === msg.id)) {
|
||||||
|
const isSelf = msg.sender_type === 'user' && msg.sender_id === currentUserId
|
||||||
|
|
||||||
messages.value = [...messages.value, {
|
messages.value = [...messages.value, {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
conversation_id: msg.conversation_id,
|
conversation_id: msg.conversation_id,
|
||||||
@@ -49,13 +55,18 @@ function handleSSEEvent(event: ChatEvent) {
|
|||||||
sender_role: msg.sender_role as ChatMessage['sender_role'],
|
sender_role: msg.sender_role as ChatMessage['sender_role'],
|
||||||
sender_name: msg.sender_name,
|
sender_name: msg.sender_name,
|
||||||
sender_avatar: '',
|
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_type: msg.content_type as ChatMessage['content_type'],
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
attachment_urls: msg.attachment_urls || [],
|
attachment_urls: msg.attachment_urls || [],
|
||||||
created_at: msg.created_at,
|
created_at: msg.created_at,
|
||||||
}]
|
}]
|
||||||
nextTick(() => scrollBottom())
|
nextTick(() => scrollBottom())
|
||||||
|
|
||||||
|
// 不是自己发送的消息才发送通知
|
||||||
|
if (!isSelf) {
|
||||||
|
desktopNotification.notify(event, conversationID.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (event.type === 'conversation_updated' && event.conversation_id === 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)
|
onEvent(handleSSEEvent)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// 静默请求通知权限(用户交互后才会弹窗)
|
||||||
|
desktopNotification.requestPermissionSilently()
|
||||||
await loadAll()
|
await loadAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user