增加订单实时状态同步

This commit is contained in:
yml2213
2026-08-17 14:51:51 +08:00
parent 5330b55b75
commit 18dad9b0fe
13 changed files with 798 additions and 16 deletions
@@ -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
}
}
+45
View File
@@ -44,10 +44,12 @@ import {
import {
clearAdminSession,
getAdminRole,
getAdminToken,
getAdminTokenExpiresAt,
getAdminUsername,
} from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
const SIDEBAR_COLLAPSED_KEY = 'react-admin-sidebar-collapsed'
const NOTIFICATION_SOUND_KEY = 'admin-notification-sound-enabled'
@@ -88,6 +90,15 @@ export default function AdminLayout() {
const notifications = notificationsQuery.data?.data.items || []
const pendingNotificationCount = Number(notificationsQuery.data?.data.pendingCount || 0)
useRealtimeEvents({
url: '/api/v1/admin/realtime',
token: getAdminToken(),
onEvent: (event) => handleAdminRealtimeEvent(event, queryClient),
onConnected: () => {
void invalidateAdminRealtimeQueries(queryClient)
},
})
useEffect(() => {
const currentVersions = new Map(
notifications.map((notification) => [notification.notificationId, notification.updatedAt]),
@@ -343,6 +354,40 @@ export default function AdminLayout() {
)
}
function handleAdminRealtimeEvent(
event: RealtimeEvent,
queryClient: ReturnType<typeof useQueryClient>,
) {
if (event.type === 'admin_notification.changed') {
void queryClient.invalidateQueries({ queryKey: ['admin-notifications'] })
}
if (event.type === 'order.changed') {
void queryClient.invalidateQueries({ queryKey: ['admin-orders'] })
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', String(event.entityId)] })
void queryClient.invalidateQueries({ queryKey: ['admin-tasks'] })
void queryClient.invalidateQueries({ queryKey: ['admin-dashboard-summary'] })
}
if (event.type === 'work_order.changed') {
void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] })
void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] })
}
if (event.type === 'worker_finance_request.changed') {
void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-finance-requests'] })
}
}
function invalidateAdminRealtimeQueries(queryClient: ReturnType<typeof useQueryClient>) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-notifications'] }),
queryClient.invalidateQueries({ queryKey: ['admin-orders'] }),
queryClient.invalidateQueries({ queryKey: ['admin-tasks'] }),
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-summary'] }),
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }),
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-finance-requests'] }),
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] }),
])
}
type NotificationAlertKind =
| 'withdraw'
| 'recharge'
@@ -5,12 +5,15 @@ import {
UserOutlined,
} from '@ant-design/icons'
import { App, Button, Layout, Menu, Typography } from 'antd'
import { useQueryClient } from '@tanstack/react-query'
import type { MenuProps } from 'antd'
import { Outlet, useLocation, useNavigate } from 'react-router'
import { logoutWorker } from '@/services/worker'
import { useIsMobile } from '@/utils/use-is-mobile'
import { clearWorkerSession, getWorkerStatus, getWorkerUsername } from '@/utils/worker-auth'
import { getWorkerToken } from '@/utils/worker-auth'
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
const menuItems: MenuProps['items'] = [
{ key: '/hall', icon: <ShoppingOutlined />, label: '抢单大厅' },
@@ -31,8 +34,18 @@ export default function WorkerLayout() {
const { message, modal } = App.useApp()
const username = getWorkerUsername() || '打手'
const status = getWorkerStatus()
const queryClient = useQueryClient()
const currentKey = resolveSelectedKey(location.pathname)
useRealtimeEvents({
url: '/api/v1/worker/realtime',
token: getWorkerToken(),
onEvent: (event) => handleWorkerRealtimeEvent(event, queryClient),
onConnected: () => {
void invalidateWorkerRealtimeQueries(queryClient)
},
})
async function submitLogout() {
try {
await logoutWorker()
@@ -143,6 +156,42 @@ export default function WorkerLayout() {
)
}
function handleWorkerRealtimeEvent(
event: RealtimeEvent,
queryClient: ReturnType<typeof useQueryClient>,
) {
if (event.type === 'work_order.changed') {
if (event.scopes.includes('worker_hall')) {
void queryClient.invalidateQueries({ queryKey: ['worker-hall-orders'] })
void queryClient.invalidateQueries({ queryKey: ['worker-hall-summary'] })
}
if (event.scopes.includes('worker_my_orders')) {
void queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] })
void queryClient.invalidateQueries({ queryKey: ['worker-my-orders-overview'] })
}
}
if (event.type === 'worker_finance_request.changed') {
void queryClient.invalidateQueries({ queryKey: ['worker-finance-requests'] })
void queryClient.invalidateQueries({ queryKey: ['worker-profile'] })
}
if (event.type === 'worker_wallet.changed') {
void queryClient.invalidateQueries({ queryKey: ['worker-wallet-ledgers'] })
void queryClient.invalidateQueries({ queryKey: ['worker-profile'] })
}
}
function invalidateWorkerRealtimeQueries(queryClient: ReturnType<typeof useQueryClient>) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: ['worker-hall-orders'] }),
queryClient.invalidateQueries({ queryKey: ['worker-hall-summary'] }),
queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }),
queryClient.invalidateQueries({ queryKey: ['worker-my-orders-overview'] }),
queryClient.invalidateQueries({ queryKey: ['worker-profile'] }),
queryClient.invalidateQueries({ queryKey: ['worker-wallet-ledgers'] }),
queryClient.invalidateQueries({ queryKey: ['worker-finance-requests'] }),
])
}
function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/orders')) return '/orders'
if (pathname.startsWith('/profile')) return '/profile'