优化客服消息通知提醒

This commit is contained in:
yml2213
2026-06-08 06:36:31 +08:00
parent 9d6b1d945c
commit 79ea3a476c
6 changed files with 385 additions and 168 deletions
+53 -37
View File
@@ -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>