SSE 推送替代轮训

This commit is contained in:
yml2213
2026-05-27 06:18:55 +08:00
parent 1458fc279c
commit a002987784
9 changed files with 493 additions and 42 deletions
+91
View File
@@ -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 }
}
+34 -15
View File
@@ -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 {
+41 -14
View File
@@ -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 {