优化客服消息通知提醒
This commit is contained in:
@@ -18,6 +18,7 @@ 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 NotificationSettings from '@/features/chats/components/NotificationSettings.vue'
|
||||
import { formatDateMinute } from '@/utils/time'
|
||||
import TransferDialog from '../components/TransferDialog.vue'
|
||||
import QuickReplyDialog from '../components/QuickReplyDialog.vue'
|
||||
@@ -44,11 +45,13 @@ const remarkValue = ref('')
|
||||
|
||||
const activeMembers = computed(() => {
|
||||
const participants = active.value?.participants || []
|
||||
return participants.map(item => {
|
||||
const remark = getParticipantRemark(item)
|
||||
const name = remark ? `${remark}(${item.display_name})` : item.display_name
|
||||
return `${roleLabel(item.role)}:${name}`
|
||||
}).join(' / ')
|
||||
return participants
|
||||
.map(item => {
|
||||
const remark = getParticipantRemark(item)
|
||||
const name = remark ? `${remark}(${item.display_name})` : item.display_name
|
||||
return `${roleLabel(item.role)}:${name}`
|
||||
})
|
||||
.join(' / ')
|
||||
})
|
||||
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
|
||||
|
||||
@@ -67,31 +70,40 @@ function handleSSEEvent(event: ChatEvent) {
|
||||
if (event.type === 'conversation_updated') {
|
||||
loadConversations(false)
|
||||
}
|
||||
if (event.type === 'new_message' && active.value && event.conversation_id === active.value.id) {
|
||||
if (event.type === 'new_message') {
|
||||
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,
|
||||
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,
|
||||
}]
|
||||
if (!msg) return
|
||||
const isSelf = msg.sender_type === 'admin' && msg.sender_id === currentAdminId
|
||||
if (
|
||||
active.value &&
|
||||
event.conversation_id === active.value.id &&
|
||||
!messages.value.some(m => m.id === msg.id)
|
||||
) {
|
||||
messages.value = [
|
||||
...messages.value,
|
||||
{
|
||||
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,
|
||||
},
|
||||
]
|
||||
nextTick(() => scrollBottom())
|
||||
}
|
||||
|
||||
// 不是自己发送的消息才发送通知
|
||||
if (!isSelf) {
|
||||
desktopNotification.notify(event, active.value.id)
|
||||
}
|
||||
loadConversations(false)
|
||||
|
||||
// 不是自己发送的消息才发送通知;当前正在看的会话且页面前台时会在 notify 内部静默。
|
||||
if (!isSelf) {
|
||||
desktopNotification.notify(event, active.value?.id ?? null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,7 +136,9 @@ async function loadConversations(showLoading = true) {
|
||||
async function loadQuickReplies() {
|
||||
try {
|
||||
quickReplies.value = await fetchQuickReplies()
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function openConversation(item: ChatConversation) {
|
||||
@@ -212,7 +226,10 @@ async function uploadImages(files: File[]) {
|
||||
uploading.value = true
|
||||
try {
|
||||
for (const file of files.slice(0, slots)) {
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 25 * 1024 * 1024) {
|
||||
if (
|
||||
!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) ||
|
||||
file.size > 25 * 1024 * 1024
|
||||
) {
|
||||
ElMessage.warning(`${file.name} 不符合图片规则`)
|
||||
continue
|
||||
}
|
||||
@@ -324,6 +341,7 @@ function getSupportName(item: ChatConversation) {
|
||||
<p>处理订单三方沟通和平台咨询</p>
|
||||
</div>
|
||||
<div class="head-right">
|
||||
<NotificationSettings scope="admin" />
|
||||
<el-button @click="quickReplyVisible = true">快捷回复管理</el-button>
|
||||
<el-button :loading="loading" @click="loadConversations()">刷新</el-button>
|
||||
</div>
|
||||
@@ -350,7 +368,12 @@ function getSupportName(item: ChatConversation) {
|
||||
<strong>{{ getConversationTitle(item) }}</strong>
|
||||
<span>{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
|
||||
</div>
|
||||
<p>{{ item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建') }}</p>
|
||||
<p>
|
||||
{{
|
||||
item.last_message_preview ||
|
||||
(item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
|
||||
}}
|
||||
</p>
|
||||
<div class="row-meta">
|
||||
<span class="support-name">{{ getSupportName(item) }}</span>
|
||||
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
|
||||
@@ -375,7 +398,10 @@ function getSupportName(item: ChatConversation) {
|
||||
<el-button size="small" @click="remarkEditing = false">取消</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h2>{{ getConversationTitle(active) }} <el-button link size="small" @click="startEditRemark">编辑备注</el-button></h2>
|
||||
<h2>
|
||||
{{ getConversationTitle(active) }}
|
||||
<el-button link size="small" @click="startEditRemark">编辑备注</el-button>
|
||||
</h2>
|
||||
<p>{{ activeMembers }}</p>
|
||||
</template>
|
||||
</div>
|
||||
@@ -395,7 +421,7 @@ function getSupportName(item: ChatConversation) {
|
||||
:class="{
|
||||
self: item.is_self,
|
||||
system: item.sender_type === 'system',
|
||||
admin: item.sender_role === 'admin'
|
||||
admin: item.sender_role === 'admin',
|
||||
}"
|
||||
>
|
||||
<template v-if="item.sender_type === 'system'">
|
||||
@@ -427,7 +453,7 @@ function getSupportName(item: ChatConversation) {
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
@change="handleImageChange"
|
||||
>
|
||||
/>
|
||||
<el-dropdown trigger="click" @command="handleQuickReplySelect">
|
||||
<el-button size="small" text>快捷回复</el-button>
|
||||
<template #dropdown>
|
||||
@@ -438,7 +464,10 @@ function getSupportName(item: ChatConversation) {
|
||||
:command="reply"
|
||||
>
|
||||
<span class="reply-title">{{ reply.title }}</span>
|
||||
<span class="reply-preview">{{ reply.content.slice(0, 30) }}{{ reply.content.length > 30 ? '...' : '' }}</span>
|
||||
<span class="reply-preview"
|
||||
>{{ reply.content.slice(0, 30)
|
||||
}}{{ reply.content.length > 30 ? '...' : '' }}</span
|
||||
>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="quickReplies.length === 0" disabled>
|
||||
暂无快捷回复
|
||||
@@ -446,7 +475,14 @@ function getSupportName(item: ChatConversation) {
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button size="small" text :icon="Picture" :loading="uploading" :disabled="attachments.length >= 9" @click="pickImages">
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
:icon="Picture"
|
||||
:loading="uploading"
|
||||
:disabled="attachments.length >= 9"
|
||||
@click="pickImages"
|
||||
>
|
||||
图片
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -469,7 +505,13 @@ function getSupportName(item: ChatConversation) {
|
||||
@keydown.enter.exact.prevent="handleSend"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
<el-button type="primary" :loading="sending" :disabled="!canSend || uploading" @click="handleSend">发送</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="sending"
|
||||
:disabled="!canSend || uploading"
|
||||
@click="handleSend"
|
||||
>发送</el-button
|
||||
>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -484,10 +526,7 @@ function getSupportName(item: ChatConversation) {
|
||||
@success="handleTransferSuccess"
|
||||
/>
|
||||
|
||||
<QuickReplyDialog
|
||||
v-model="quickReplyVisible"
|
||||
@success="loadQuickReplies"
|
||||
/>
|
||||
<QuickReplyDialog v-model="quickReplyVisible" @success="loadQuickReplies" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -519,6 +558,8 @@ function getSupportName(item: ChatConversation) {
|
||||
|
||||
.head-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ const statusType = computed(() => {
|
||||
return 'info'
|
||||
})
|
||||
|
||||
const voiceStatusText = computed(() => {
|
||||
if (!notification.isSpeechSupported) return '语音不支持'
|
||||
return notification.voiceEnabled.value ? '语音已开' : '语音关闭'
|
||||
})
|
||||
|
||||
async function toggleNotification() {
|
||||
if (!notification.isSupported) return
|
||||
|
||||
@@ -32,36 +37,59 @@ async function toggleNotification() {
|
||||
alert('通知权限已被拒绝,请在浏览器设置中手动开启')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVoice() {
|
||||
if (!notification.isSpeechSupported) return
|
||||
notification.setVoiceEnabled(!notification.voiceEnabled.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notification-settings">
|
||||
<el-tooltip
|
||||
content="接收新消息的桌面通知"
|
||||
placement="bottom"
|
||||
>
|
||||
<div class="setting-group">
|
||||
<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>
|
||||
|
||||
<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"
|
||||
size="small"
|
||||
:type="notification.voiceEnabled.value ? 'success' : 'default'"
|
||||
:disabled="!notification.isSpeechSupported"
|
||||
plain
|
||||
@click="toggleVoice"
|
||||
>
|
||||
<template #default>
|
||||
<el-badge
|
||||
v-if="notification.permission.value !== 'granted' && notification.isSupported"
|
||||
is-dot
|
||||
:type="statusType"
|
||||
/>
|
||||
</template>
|
||||
{{ voiceStatusText }}
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<span class="status-text">{{ statusText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-settings {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
@@ -6,6 +6,12 @@ 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
|
||||
@@ -38,22 +44,28 @@ export function useDesktopNotification(scope: 'user' | 'admin') {
|
||||
/**
|
||||
* 检查是否应该发送通知
|
||||
*/
|
||||
function shouldNotify(conversationId: number, currentConversationId: number | null): boolean {
|
||||
// 不支持通知
|
||||
if (!isSupported || permission.value !== 'granted') return false
|
||||
|
||||
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
|
||||
if (!shouldNotify(event.conversation_id, currentConversationId)) return
|
||||
|
||||
const { sender_name, sender_role, content, attachment_urls } = event.message
|
||||
|
||||
@@ -78,33 +90,40 @@ export function useDesktopNotification(scope: 'user' | 'admin') {
|
||||
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,
|
||||
})
|
||||
if (!shouldAlert(event.conversation_id, currentConversationId)) return
|
||||
|
||||
// 点击通知跳转到对应会话
|
||||
notification.onclick = () => {
|
||||
window.focus()
|
||||
const path = scope === 'admin'
|
||||
? `/admin/chats/${event.conversation_id}`
|
||||
: `/messages/${event.conversation_id}`
|
||||
router.push(path)
|
||||
notification.close()
|
||||
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'
|
||||
? `/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)
|
||||
}
|
||||
|
||||
// 5秒后自动关闭
|
||||
setTimeout(() => {
|
||||
notification.close()
|
||||
}, 5000)
|
||||
} catch (error) {
|
||||
console.error('Failed to show notification:', error)
|
||||
}
|
||||
|
||||
speakMessage(roleLabel, sender_name, body)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,11 +143,68 @@ export function useDesktopNotification(scope: 'user' | 'admin') {
|
||||
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) {
|
||||
console.error('Failed to speak notification:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ const conversationID = computed(() => Number(route.params.id || 0))
|
||||
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
|
||||
const memberText = computed(() => {
|
||||
const participants = conversation.value?.participants || []
|
||||
if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
|
||||
if (participants.length === 0)
|
||||
return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
|
||||
return participants.map(item => `${roleLabel(item.role)}:${item.display_name}`).join(' / ')
|
||||
})
|
||||
|
||||
@@ -43,27 +44,28 @@ const memberText = computed(() => {
|
||||
const desktopNotification = useDesktopNotification('user')
|
||||
|
||||
function handleSSEEvent(event: ChatEvent) {
|
||||
if (event.type === 'new_message' && event.conversation_id === conversationID.value) {
|
||||
if (event.type === 'new_message') {
|
||||
const msg = event.message
|
||||
if (msg) {
|
||||
const isSelf = msg.sender_type === 'user' && msg.sender_id === currentUserId
|
||||
if (event.conversation_id === conversationID.value) {
|
||||
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,
|
||||
})
|
||||
|
||||
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(() => {})
|
||||
markChatRead(conversationID.value).catch(() => {})
|
||||
}
|
||||
|
||||
// 不是自己发送的消息才发送通知
|
||||
if (!isSelf) {
|
||||
@@ -97,10 +99,7 @@ async function loadAll() {
|
||||
conversation.value = null
|
||||
messages.value = []
|
||||
try {
|
||||
const [chat] = await Promise.all([
|
||||
fetchChat(conversationID.value),
|
||||
loadMessages(true),
|
||||
])
|
||||
const [chat] = await Promise.all([fetchChat(conversationID.value), loadMessages(true)])
|
||||
conversation.value = chat
|
||||
await markChatRead(conversationID.value)
|
||||
} catch {
|
||||
@@ -114,7 +113,9 @@ async function loadConversation() {
|
||||
if (!conversationID.value) return
|
||||
try {
|
||||
conversation.value = await fetchChat(conversationID.value)
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(scrollToBottom = true) {
|
||||
@@ -180,7 +181,10 @@ async function uploadImages(files: File[]) {
|
||||
uploading.value = true
|
||||
try {
|
||||
for (const file of files.slice(0, slots)) {
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 25 * 1024 * 1024) {
|
||||
if (
|
||||
!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) ||
|
||||
file.size > 25 * 1024 * 1024
|
||||
) {
|
||||
ElMessage.warning(`${file.name} 不符合图片规则`)
|
||||
continue
|
||||
}
|
||||
@@ -279,25 +283,26 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
:class="{
|
||||
self: item.is_self,
|
||||
system: item.sender_type === 'system',
|
||||
admin: item.sender_role === 'admin' && !item.is_self
|
||||
admin: item.sender_role === 'admin' && !item.is_self,
|
||||
}"
|
||||
>
|
||||
<template v-if="item.sender_type === 'system'">
|
||||
<span class="system-message">{{ item.content }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="avatar" :class="{ self: item.is_self }">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
|
||||
<div class="avatar" :class="{ self: item.is_self }">
|
||||
{{ roleLabel(item.sender_role).slice(0, 1) }}
|
||||
</div>
|
||||
<div class="bubble-wrap" :class="{ self: item.is_self }">
|
||||
<span class="sender-name" :class="{ 'admin-label': item.sender_role === 'admin' && !item.is_self }">
|
||||
<span
|
||||
class="sender-name"
|
||||
:class="{ 'admin-label': item.sender_role === 'admin' && !item.is_self }"
|
||||
>
|
||||
{{ senderLabel(item) }}
|
||||
</span>
|
||||
<div v-if="item.content" class="bubble">{{ item.content }}</div>
|
||||
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
|
||||
<ChatAttachmentImage
|
||||
v-for="url in item.attachment_urls"
|
||||
:key="url"
|
||||
:source="url"
|
||||
/>
|
||||
<ChatAttachmentImage v-for="url in item.attachment_urls" :key="url" :source="url" />
|
||||
</div>
|
||||
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
|
||||
</div>
|
||||
@@ -322,7 +327,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
@change="handleImageChange"
|
||||
>
|
||||
/>
|
||||
<el-input
|
||||
v-model="content"
|
||||
type="textarea"
|
||||
@@ -337,10 +342,20 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
<div class="composer-actions">
|
||||
<span class="composer-hint">Enter 发送,Shift+Enter 换行</span>
|
||||
<div class="composer-buttons">
|
||||
<el-button :icon="Picture" :loading="uploading" :disabled="attachments.length >= 9" @click="pickImages">
|
||||
<el-button
|
||||
:icon="Picture"
|
||||
:loading="uploading"
|
||||
:disabled="attachments.length >= 9"
|
||||
@click="pickImages"
|
||||
>
|
||||
图片
|
||||
</el-button>
|
||||
<el-button type="primary" :disabled="!canSend || sending || uploading" :loading="sending" @click="handleSend">
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!canSend || sending || uploading"
|
||||
:loading="sending"
|
||||
@click="handleSend"
|
||||
>
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -350,7 +365,6 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.chat-page {
|
||||
max-width: 1720px;
|
||||
@@ -389,7 +403,9 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
|
||||
@@ -5,10 +5,12 @@ 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 { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
|
||||
import NotificationSettings from '@/features/chats/components/NotificationSettings.vue'
|
||||
import { formatDateMinute } from '@/utils/time'
|
||||
|
||||
const router = useRouter()
|
||||
const currentUserId = Number(localStorage.getItem('user_id') || 0)
|
||||
const loading = ref(false)
|
||||
const conversations = ref<ChatConversation[]>([])
|
||||
const page = ref(1)
|
||||
@@ -16,12 +18,18 @@ const pageSize = 20
|
||||
const total = ref(0)
|
||||
|
||||
const hasMore = computed(() => conversations.value.length < total.value)
|
||||
const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum + item.unread_count, 0))
|
||||
const unreadTotal = computed(() =>
|
||||
conversations.value.reduce((sum, item) => sum + item.unread_count, 0)
|
||||
)
|
||||
|
||||
const desktopNotification = useDesktopNotification('user')
|
||||
const { onEvent } = useChatSSE('user', '/api/chats/events')
|
||||
onEvent(handleSSEEvent)
|
||||
|
||||
onMounted(() => loadChats(true))
|
||||
onMounted(() => {
|
||||
desktopNotification.requestPermissionSilently()
|
||||
loadChats(true)
|
||||
})
|
||||
|
||||
async function loadChats(isRefresh = false, showLoading = true) {
|
||||
if (isRefresh) page.value = 1
|
||||
@@ -45,6 +53,12 @@ function handleSSEEvent(event: ChatEvent) {
|
||||
if (event.type === 'new_message' || event.type === 'conversation_updated') {
|
||||
loadChats(true, false)
|
||||
}
|
||||
if (event.type === 'new_message' && event.message) {
|
||||
const isSelf = event.message.sender_type === 'user' && event.message.sender_id === currentUserId
|
||||
if (!isSelf) {
|
||||
desktopNotification.notify(event, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openConversation(item: ChatConversation) {
|
||||
@@ -52,12 +66,20 @@ function openConversation(item: ChatConversation) {
|
||||
}
|
||||
|
||||
function roleLabel(role: string) {
|
||||
const map: Record<string, string> = { renter: '租客', owner: '号主', support: '客服', customer: '咨询' }
|
||||
const map: Record<string, string> = {
|
||||
renter: '租客',
|
||||
owner: '号主',
|
||||
support: '客服',
|
||||
customer: '咨询',
|
||||
}
|
||||
return map[role] || '成员'
|
||||
}
|
||||
|
||||
function previewText(item: ChatConversation) {
|
||||
return item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
|
||||
return (
|
||||
item.last_message_preview ||
|
||||
(item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -72,7 +94,9 @@ function previewText(item: ChatConversation) {
|
||||
<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>
|
||||
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')"
|
||||
>我的订单</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +104,9 @@ function previewText(item: ChatConversation) {
|
||||
|
||||
<div v-else-if="!loading && conversations.length === 0" class="empty-panel">
|
||||
<el-empty description="暂无会话">
|
||||
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">查看订单</el-button>
|
||||
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')"
|
||||
>查看订单</el-button
|
||||
>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
@@ -99,11 +125,15 @@ function previewText(item: ChatConversation) {
|
||||
<div class="conversation-body">
|
||||
<div class="conversation-head">
|
||||
<h2>{{ item.title }}</h2>
|
||||
<span class="conversation-time">{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
|
||||
<span class="conversation-time">{{
|
||||
formatDateMinute(item.last_message_at || item.created_at)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="conversation-meta">
|
||||
<span class="role-chip">{{ roleLabel(item.role) }}</span>
|
||||
<span class="order-id">{{ item.order_id ? `订单 #${item.order_id}` : '平台客服' }}</span>
|
||||
<span class="order-id">{{
|
||||
item.order_id ? `订单 #${item.order_id}` : '平台客服'
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="conversation-preview">{{ previewText(item) }}</p>
|
||||
</div>
|
||||
@@ -167,7 +197,9 @@ function previewText(item: ChatConversation) {
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ const conversationID = computed(() => Number(route.params.id || 0))
|
||||
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
|
||||
const memberText = computed(() => {
|
||||
const participants = conversation.value?.participants || []
|
||||
if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
|
||||
if (participants.length === 0)
|
||||
return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
|
||||
return participants.map(item => `${roleLabel(item.role)}:${item.display_name}`).join(' / ')
|
||||
})
|
||||
|
||||
@@ -42,26 +43,33 @@ const memberText = computed(() => {
|
||||
const desktopNotification = useDesktopNotification('user')
|
||||
|
||||
function handleSSEEvent(event: ChatEvent) {
|
||||
if (event.type === 'new_message' && event.conversation_id === conversationID.value) {
|
||||
if (event.type === 'new_message') {
|
||||
const msg = event.message
|
||||
if (msg && !messages.value.some(m => m.id === msg.id)) {
|
||||
if (msg) {
|
||||
const isSelf = msg.sender_type === 'user' && msg.sender_id === currentUserId
|
||||
|
||||
messages.value = [...messages.value, {
|
||||
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,
|
||||
}]
|
||||
nextTick(() => scrollBottom())
|
||||
if (
|
||||
event.conversation_id === conversationID.value &&
|
||||
!messages.value.some(m => m.id === msg.id)
|
||||
) {
|
||||
messages.value = [
|
||||
...messages.value,
|
||||
{
|
||||
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,
|
||||
},
|
||||
]
|
||||
nextTick(() => scrollBottom())
|
||||
}
|
||||
|
||||
// 不是自己发送的消息才发送通知
|
||||
if (!isSelf) {
|
||||
@@ -87,10 +95,7 @@ async function loadAll() {
|
||||
if (!conversationID.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const [chat] = await Promise.all([
|
||||
fetchChat(conversationID.value),
|
||||
loadMessages(false),
|
||||
])
|
||||
const [chat] = await Promise.all([fetchChat(conversationID.value), loadMessages(false)])
|
||||
conversation.value = chat
|
||||
await markChatRead(conversationID.value)
|
||||
} catch {
|
||||
@@ -104,7 +109,9 @@ async function loadConversation() {
|
||||
if (!conversationID.value) return
|
||||
try {
|
||||
conversation.value = await fetchChat(conversationID.value)
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(scrollToBottom = true) {
|
||||
@@ -175,7 +182,10 @@ async function uploadImages(files: File[]) {
|
||||
uploading.value = true
|
||||
try {
|
||||
for (const file of files.slice(0, slots)) {
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 25 * 1024 * 1024) {
|
||||
if (
|
||||
!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) ||
|
||||
file.size > 25 * 1024 * 1024
|
||||
) {
|
||||
showToast(`${file.name} 不符合图片规则`)
|
||||
continue
|
||||
}
|
||||
@@ -234,7 +244,12 @@ function senderLabel(message: ChatMessage) {
|
||||
<h1>{{ conversation?.title || '客服会话' }}</h1>
|
||||
<p>{{ memberText }}</p>
|
||||
</div>
|
||||
<button v-if="conversation?.order_id" class="icon-btn" type="button" @click="router.push(`/m/orders/${conversation.order_id}`)">
|
||||
<button
|
||||
v-if="conversation?.order_id"
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
@click="router.push(`/m/orders/${conversation.order_id}`)"
|
||||
>
|
||||
<van-icon name="orders-o" :size="20" />
|
||||
</button>
|
||||
<span v-else class="icon-placeholder"></span>
|
||||
@@ -249,7 +264,7 @@ function senderLabel(message: ChatMessage) {
|
||||
:class="{
|
||||
self: item.is_self,
|
||||
system: item.sender_type === 'system',
|
||||
admin: item.sender_role === 'admin' && !item.is_self
|
||||
admin: item.sender_role === 'admin' && !item.is_self,
|
||||
}"
|
||||
>
|
||||
<template v-if="item.sender_type === 'system'">
|
||||
@@ -258,16 +273,15 @@ function senderLabel(message: ChatMessage) {
|
||||
<template v-else>
|
||||
<div class="avatar">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
|
||||
<div class="bubble-wrap">
|
||||
<span class="sender-name" :class="{ 'admin-label': item.sender_role === 'admin' && !item.is_self }">
|
||||
<span
|
||||
class="sender-name"
|
||||
:class="{ 'admin-label': item.sender_role === 'admin' && !item.is_self }"
|
||||
>
|
||||
{{ senderLabel(item) }}
|
||||
</span>
|
||||
<div v-if="item.content" class="bubble">{{ item.content }}</div>
|
||||
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
|
||||
<ChatAttachmentImage
|
||||
v-for="url in item.attachment_urls"
|
||||
:key="url"
|
||||
:source="url"
|
||||
/>
|
||||
<ChatAttachmentImage v-for="url in item.attachment_urls" :key="url" :source="url" />
|
||||
</div>
|
||||
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
|
||||
</div>
|
||||
@@ -283,7 +297,7 @@ function senderLabel(message: ChatMessage) {
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
@change="handleImageChange"
|
||||
>
|
||||
/>
|
||||
<div v-if="attachments.length > 0" class="pending-attachments">
|
||||
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
|
||||
<ChatAttachmentImage :source="url" />
|
||||
@@ -292,7 +306,12 @@ function senderLabel(message: ChatMessage) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="tool-btn" type="button" :disabled="uploading || attachments.length >= 9" @click="pickImages">
|
||||
<button
|
||||
class="tool-btn"
|
||||
type="button"
|
||||
:disabled="uploading || attachments.length >= 9"
|
||||
@click="pickImages"
|
||||
>
|
||||
<van-icon name="photo-o" :size="20" />
|
||||
</button>
|
||||
<van-field
|
||||
@@ -306,7 +325,12 @@ function senderLabel(message: ChatMessage) {
|
||||
@keydown.enter.prevent="handleSend"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
<button class="send-btn" type="button" :disabled="!canSend || sending || uploading" @click="handleSend">
|
||||
<button
|
||||
class="send-btn"
|
||||
type="button"
|
||||
:disabled="!canSend || sending || uploading"
|
||||
@click="handleSend"
|
||||
>
|
||||
<van-icon name="guide-o" :size="20" />
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user