增加订单实时状态同步
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export type RealtimeEvent = {
|
||||
eventId: number
|
||||
type:
|
||||
| 'admin_notification.changed'
|
||||
| 'order.changed'
|
||||
| 'work_order.changed'
|
||||
| 'worker_finance_request.changed'
|
||||
| 'worker_wallet.changed'
|
||||
entityId: number
|
||||
scopes: string[]
|
||||
occurredAt: string
|
||||
}
|
||||
|
||||
type UseRealtimeEventsOptions = {
|
||||
url: string
|
||||
token: string
|
||||
onEvent: (event: RealtimeEvent) => void
|
||||
onConnected?: () => void
|
||||
}
|
||||
|
||||
const RECONNECT_DELAY_MS = 3_000
|
||||
|
||||
/** 通过 fetch 保持带 Bearer 鉴权的 SSE 连接,避免把登录 token 暴露到 URL。 */
|
||||
export function useRealtimeEvents({ url, token, onEvent, onConnected }: UseRealtimeEventsOptions) {
|
||||
const onEventRef = useRef(onEvent)
|
||||
const onConnectedRef = useRef(onConnected)
|
||||
onEventRef.current = onEvent
|
||||
onConnectedRef.current = onConnected
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
|
||||
let stopped = false
|
||||
let abortController: AbortController | null = null
|
||||
let reconnectTimer = 0
|
||||
|
||||
const connect = () => {
|
||||
if (stopped || document.visibilityState !== 'visible') return
|
||||
abortController?.abort()
|
||||
abortController = new AbortController()
|
||||
|
||||
void fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`realtime_stream_${response.status}`)
|
||||
}
|
||||
onConnectedRef.current?.()
|
||||
await readSseStream(response.body, (eventName, data) => {
|
||||
if (eventName !== 'realtime') return
|
||||
const event = normalizeRealtimeEvent(data)
|
||||
if (event) onEventRef.current(event)
|
||||
})
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (stopped || document.visibilityState !== 'visible') return
|
||||
reconnectTimer = window.setTimeout(connect, RECONNECT_DELAY_MS)
|
||||
})
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
connect()
|
||||
} else {
|
||||
abortController?.abort()
|
||||
window.clearTimeout(reconnectTimer)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
connect()
|
||||
return () => {
|
||||
stopped = true
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
abortController?.abort()
|
||||
window.clearTimeout(reconnectTimer)
|
||||
}
|
||||
}, [token, url])
|
||||
}
|
||||
|
||||
async function readSseStream(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
onMessage: (eventName: string, data: string) => void,
|
||||
) {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let eventName = 'message'
|
||||
let dataLines: string[] = []
|
||||
|
||||
function dispatch() {
|
||||
if (dataLines.length > 0) {
|
||||
onMessage(eventName, dataLines.join('\n'))
|
||||
}
|
||||
eventName = 'message'
|
||||
dataLines = []
|
||||
}
|
||||
|
||||
function parseLine(line: string) {
|
||||
if (!line) {
|
||||
dispatch()
|
||||
return
|
||||
}
|
||||
if (line.startsWith(':')) return
|
||||
const separatorIndex = line.indexOf(':')
|
||||
const field = separatorIndex >= 0 ? line.slice(0, separatorIndex) : line
|
||||
const value = separatorIndex >= 0 ? line.slice(separatorIndex + 1).replace(/^ /, '') : ''
|
||||
if (field === 'event') eventName = value || 'message'
|
||||
if (field === 'data') dataLines.push(value)
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split(/\r?\n/)
|
||||
buffer = lines.pop() || ''
|
||||
lines.forEach(parseLine)
|
||||
}
|
||||
buffer += decoder.decode()
|
||||
if (buffer) parseLine(buffer)
|
||||
dispatch()
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRealtimeEvent(value: string): RealtimeEvent | null {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<RealtimeEvent>
|
||||
const type = String(parsed.type || '')
|
||||
if (
|
||||
![
|
||||
'admin_notification.changed',
|
||||
'order.changed',
|
||||
'work_order.changed',
|
||||
'worker_finance_request.changed',
|
||||
'worker_wallet.changed',
|
||||
].includes(type)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
eventId: Number(parsed.eventId || 0),
|
||||
type: type as RealtimeEvent['type'],
|
||||
entityId: Number(parsed.entityId || 0),
|
||||
scopes: Array.isArray(parsed.scopes)
|
||||
? parsed.scopes.map((item) => String(item || '')).filter(Boolean)
|
||||
: [],
|
||||
occurredAt: String(parsed.occurredAt || ''),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user