feat(messages): 接入用户聊天 SSE 实时刷新

This commit is contained in:
yml2213
2026-10-30 14:15:16 +08:00
parent 395f056d64
commit ca2c8fee26
4 changed files with 154 additions and 1 deletions
+2
View File
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import { AppRouter } from '@/app/AppRouter'
import { ChatEventsBridge } from '@/features/messages/ChatEventsBridge'
import { useSessionStore } from '@/stores/session'
import { jieyouTheme } from '@/theme'
@@ -41,6 +42,7 @@ export default function App() {
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<SessionBootstrap />
<ChatEventsBridge />
<AppRouter />
</BrowserRouter>
</QueryClientProvider>
@@ -0,0 +1,118 @@
import { useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useSessionStore } from '@/stores/session'
interface ChatEvent {
type: string
conversation_id: number
}
function getSiteCode() {
try {
return sessionStorage.getItem('jieyou_site_code') || ''
} catch {
return ''
}
}
function parseEventBlock(block: string): ChatEvent | null {
let data = ''
for (const rawLine of block.split('\n')) {
const line = rawLine.replace(/\r$/, '')
if (!line.startsWith('data:')) continue
data += line.slice(5).trimStart() + '\n'
}
const payload = data.trim()
if (!payload) return null
try {
const parsed = JSON.parse(payload) as Partial<ChatEvent>
if (!parsed.type || !parsed.conversation_id) return null
return { type: parsed.type, conversation_id: Number(parsed.conversation_id) }
} catch {
return null
}
}
/**
* 用户端聊天 SSE 桥接:EventSource 无法附加 Authorization,这里用 fetch 流读取。
* SSE 只作为实时加速,页面仍保留低频轮询兜底。
*/
export function ChatEventsBridge() {
const queryClient = useQueryClient()
const token = useSessionStore(state => state.token)
useEffect(() => {
if (!token) return
const controller = new AbortController()
let stopped = false
let retryTimer: number | undefined
const invalidate = (conversationID: number) => {
void queryClient.invalidateQueries({ queryKey: ['chat-conversations'] })
void queryClient.invalidateQueries({ queryKey: ['chat-unread-count'] })
void queryClient.invalidateQueries({ queryKey: ['notifications'] })
void queryClient.invalidateQueries({
queryKey: ['chat-messages', String(conversationID)],
})
void queryClient.invalidateQueries({
queryKey: ['chat-conversation', String(conversationID)],
})
}
const connect = async () => {
try {
const headers: Record<string, string> = {
Accept: 'text/event-stream',
Authorization: `Bearer ${token}`,
}
const siteCode = getSiteCode()
if (siteCode) headers['X-Distribution-Site-Code'] = siteCode
const response = await fetch('/api/chats/events', {
headers,
credentials: 'include',
signal: controller.signal,
})
if (!response.ok || !response.body) {
throw new Error(`chat events unavailable: ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (!stopped) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n')
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const block = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const event = parseEventBlock(block)
if (event) invalidate(event.conversation_id)
boundary = buffer.indexOf('\n\n')
}
}
} catch {
// 断线后延迟重连;页面轮询仍可兜底。
}
if (!stopped) {
retryTimer = window.setTimeout(() => void connect(), 3000)
}
}
void connect()
return () => {
stopped = true
controller.abort()
if (retryTimer) window.clearTimeout(retryTimer)
}
}, [queryClient, token])
return null
}
+1 -1
View File
@@ -84,7 +84,7 @@ export function useChatMessages(id?: string, enabled = true) {
getNextPageParam: last =>
last.page * last.page_size < last.total ? last.page + 1 : undefined,
enabled: Boolean(id) && enabled,
refetchInterval: 6000,
refetchInterval: 30_000,
})
}