SSE 推送替代轮训
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { onBeforeUnmount, ref, type Ref } from 'vue'
|
||||
import { getAccessToken, type AuthScope } from '@/utils/authStorage'
|
||||
|
||||
export interface SSEMessage {
|
||||
id: number
|
||||
conversation_id: number
|
||||
sender_type: string
|
||||
sender_id: number
|
||||
sender_role: string
|
||||
sender_name: string
|
||||
content_type: string
|
||||
content: string
|
||||
attachment_urls: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ChatEvent {
|
||||
type: 'new_message' | 'conversation_updated'
|
||||
conversation_id: number
|
||||
message?: SSEMessage
|
||||
}
|
||||
|
||||
type EventHandler = (event: ChatEvent) => void
|
||||
|
||||
const reconnectDelay = 3000
|
||||
|
||||
export function useChatSSE(scope: AuthScope, endpoint: string) {
|
||||
const connected: Ref<boolean> = ref(false)
|
||||
let source: EventSource | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let stopped = false
|
||||
const handlers: EventHandler[] = []
|
||||
|
||||
function onEvent(handler: EventHandler) {
|
||||
handlers.push(handler)
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const token = getAccessToken(scope)
|
||||
if (!token) return
|
||||
|
||||
const url = `${endpoint}?token=${encodeURIComponent(token)}`
|
||||
source = new EventSource(url)
|
||||
|
||||
source.addEventListener('connected', () => {
|
||||
connected.value = true
|
||||
})
|
||||
|
||||
source.addEventListener('new_message', (e) => {
|
||||
try {
|
||||
const data = JSON.parse((e as MessageEvent).data) as ChatEvent
|
||||
handlers.forEach(h => h(data))
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
source.addEventListener('conversation_updated', (e) => {
|
||||
try {
|
||||
const data = JSON.parse((e as MessageEvent).data) as ChatEvent
|
||||
handlers.forEach(h => h(data))
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
source.onerror = () => {
|
||||
connected.value = false
|
||||
source?.close()
|
||||
source = null
|
||||
if (!stopped) {
|
||||
reconnectTimer = setTimeout(connect, reconnectDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
stopped = true
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
source?.close()
|
||||
source = null
|
||||
connected.value = false
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
connect()
|
||||
|
||||
return { connected, onEvent, disconnect }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
fetchAdminChat,
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
type ChatConversation,
|
||||
type ChatMessage,
|
||||
} from '@/api/chats'
|
||||
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
|
||||
import { formatDateMinute } from '@/utils/time'
|
||||
|
||||
const currentAdminId = Number(localStorage.getItem('admin_id') || 0)
|
||||
|
||||
const conversations = ref<ChatConversation[]>([])
|
||||
const active = ref<ChatConversation | null>(null)
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
@@ -20,23 +23,43 @@ const messageLoading = ref(false)
|
||||
const sending = ref(false)
|
||||
const content = ref('')
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
let timer: number | undefined
|
||||
|
||||
const activeMembers = computed(() => {
|
||||
const participants = active.value?.participants || []
|
||||
return participants.map(item => `${roleLabel(item.role)}:${item.display_name}`).join(' / ')
|
||||
})
|
||||
|
||||
function handleSSEEvent(event: ChatEvent) {
|
||||
if (event.type === 'conversation_updated') {
|
||||
loadConversations(false)
|
||||
}
|
||||
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)) {
|
||||
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: msg.sender_type === 'admin' && msg.sender_id === currentAdminId,
|
||||
content_type: msg.content_type as ChatMessage['content_type'],
|
||||
content: msg.content,
|
||||
attachment_urls: msg.attachment_urls || [],
|
||||
created_at: msg.created_at,
|
||||
}]
|
||||
nextTick(() => scrollBottom())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { onEvent } = useChatSSE('admin', '/api/admin/chats/events')
|
||||
onEvent(handleSSEEvent)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadConversations()
|
||||
timer = window.setInterval(async () => {
|
||||
await loadConversations(false)
|
||||
if (active.value) await loadMessages(active.value.id, false)
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) window.clearInterval(timer)
|
||||
})
|
||||
|
||||
async function loadConversations(showLoading = true) {
|
||||
@@ -83,12 +106,8 @@ async function handleSend() {
|
||||
if (!active.value || !text || sending.value) return
|
||||
sending.value = true
|
||||
try {
|
||||
const message = await sendAdminChatMessage(active.value.id, text)
|
||||
messages.value = [...messages.value, message]
|
||||
await sendAdminChatMessage(active.value.id, text)
|
||||
content.value = ''
|
||||
await nextTick()
|
||||
scrollBottom()
|
||||
await loadConversations(false)
|
||||
} catch {
|
||||
ElMessage.error('发送失败')
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import {
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
type ChatConversation,
|
||||
type ChatMessage,
|
||||
} from '@/api/chats'
|
||||
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
|
||||
import { formatDateMinute } from '@/utils/time'
|
||||
|
||||
const currentUserId = Number(localStorage.getItem('user_id') || 0)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const conversation = ref<ChatConversation | null>(null)
|
||||
@@ -20,7 +23,6 @@ const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const content = ref('')
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
let timer: number | undefined
|
||||
|
||||
const conversationID = computed(() => Number(route.params.id || 0))
|
||||
const memberText = computed(() => {
|
||||
@@ -29,15 +31,37 @@ const memberText = computed(() => {
|
||||
return participants.map(item => roleLabel(item.role)).join(' · ')
|
||||
})
|
||||
|
||||
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)) {
|
||||
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: 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,
|
||||
}]
|
||||
nextTick(() => scrollBottom())
|
||||
}
|
||||
}
|
||||
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
|
||||
loadConversation()
|
||||
}
|
||||
}
|
||||
|
||||
const { onEvent } = useChatSSE('user', '/api/chats/events')
|
||||
onEvent(handleSSEEvent)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAll()
|
||||
timer = window.setInterval(() => {
|
||||
loadMessages(false)
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) window.clearInterval(timer)
|
||||
})
|
||||
|
||||
async function loadAll() {
|
||||
@@ -57,6 +81,13 @@ async function loadAll() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConversation() {
|
||||
if (!conversationID.value) return
|
||||
try {
|
||||
conversation.value = await fetchChat(conversationID.value)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function loadMessages(scrollToBottom = true) {
|
||||
if (!conversationID.value) return
|
||||
const res = await fetchChatMessages(conversationID.value, 1, 100)
|
||||
@@ -72,12 +103,8 @@ async function handleSend() {
|
||||
if (!text || sending.value) return
|
||||
sending.value = true
|
||||
try {
|
||||
const message = await sendChatMessage(conversationID.value, text)
|
||||
await sendChatMessage(conversationID.value, text)
|
||||
content.value = ''
|
||||
messages.value = [...messages.value, message]
|
||||
await markChatRead(conversationID.value)
|
||||
await nextTick()
|
||||
scrollBottom()
|
||||
} catch {
|
||||
showToast({ message: '发送失败', icon: 'cross' })
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user