148 lines
5.3 KiB
TypeScript
148 lines
5.3 KiB
TypeScript
import { useEffect, useRef, useCallback } from 'react'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { SSE_INVALIDATE_PREFIXES, QK } from './queryKeys'
|
|
import { getQueryConfig } from './useQueryConfig'
|
|
import { toast } from '@/components/Toast'
|
|
import { pushAlertToasts } from '@/components/AlertToast'
|
|
import { feedReviewEvent } from './reviewStore'
|
|
import type { StrategyAlertEvent } from './api'
|
|
|
|
/**
|
|
* 全局 SSE hook: 监听后端行情更新推送 + 策略监控通知。
|
|
*
|
|
* - 行情更新 (quotes_updated): 根据 sseRefreshPages 配置过滤 invalidation
|
|
* - 策略监控通知 (strategy_alert): 通过 onAlert 回调弹 toast
|
|
*
|
|
* 应在顶层 Layout 中调用一次。
|
|
*/
|
|
export function useQuoteStream(
|
|
enabled: boolean,
|
|
sseRefreshPages: Record<string, boolean> | undefined,
|
|
onAlert?: (alerts: StrategyAlertEvent[]) => void,
|
|
) {
|
|
const qc = useQueryClient()
|
|
const esRef = useRef<EventSource | null>(null)
|
|
const retryRef = useRef<ReturnType<typeof setTimeout>>()
|
|
const pagesRef = useRef(sseRefreshPages)
|
|
pagesRef.current = sseRefreshPages
|
|
|
|
const handleAlerts = useCallback((alerts: StrategyAlertEvent[]) => {
|
|
// depth 系统接管通知: 单独处理, 不走 strategy 回调
|
|
const depthAlerts = alerts.filter(a => a.source === 'depth')
|
|
const strategyAlerts = alerts.filter(a => a.source !== 'depth')
|
|
|
|
// depth 通知直接 toast(防刷屏: 后端已在状态切换时才推)
|
|
for (const a of depthAlerts.slice(0, 1)) {
|
|
toast(a.message, 'success')
|
|
}
|
|
|
|
// 监控告警: 用专用 AlertToast (整批只响一声, 每条都弹, 受 maxVisible 上限保护)
|
|
if (strategyAlerts.length > 0) {
|
|
// 有 onAlert 回调时走回调, 否则弹 AlertToast
|
|
if (onAlert) {
|
|
onAlert(strategyAlerts)
|
|
}
|
|
// 批量弹通知 (去掉了 slice(0,2) 截断, 让每只新命中都弹 toast; 声音整批只响一次)
|
|
pushAlertToasts(strategyAlerts as any)
|
|
}
|
|
}, [onAlert])
|
|
|
|
const enabledRef = useRef(enabled)
|
|
enabledRef.current = enabled
|
|
|
|
useEffect(() => {
|
|
// SSE 始终连接 — 监控告警不依赖实时行情开关
|
|
// (quotes_updated 行情刷新受 enabled 控制, strategy_alert 始终处理)
|
|
|
|
const connect = () => {
|
|
const es = new EventSource('/api/intraday/stream')
|
|
esRef.current = es
|
|
|
|
// sse-starlette ping 心跳走 SSE comment,不会到达这里
|
|
|
|
es.addEventListener('quotes_updated', () => {
|
|
// 实时行情未开启时不处理行情刷新
|
|
if (!enabledRef.current) return
|
|
// 根据用户配置过滤 invalidation
|
|
const pages = pagesRef.current
|
|
if (pages) {
|
|
// 只 invalidate 开启的页面对应的 prefix
|
|
const activePrefixes = SSE_INVALIDATE_PREFIXES.filter((p) => {
|
|
// 'quote-status' 始终刷新 (全局状态)
|
|
if (p === 'quote-status') return true
|
|
return pages[p] !== false
|
|
})
|
|
qc.invalidateQueries({
|
|
predicate: (query) =>
|
|
activePrefixes.some(
|
|
(prefix) => String(query.queryKey[0]).startsWith(prefix),
|
|
),
|
|
})
|
|
} else {
|
|
// 无配置时全部刷新 (向后兼容)
|
|
qc.invalidateQueries({
|
|
predicate: (query) =>
|
|
SSE_INVALIDATE_PREFIXES.some(
|
|
(prefix) => String(query.queryKey[0]).startsWith(prefix),
|
|
),
|
|
})
|
|
}
|
|
})
|
|
|
|
es.addEventListener('depth_updated', () => {
|
|
// 五档修正完成: 刷新连板梯队 + 看板封单数据。
|
|
// 不受实时行情开关限制 — 修正轮询独立于行情轮询, 用户开了修正就想看实时封单。
|
|
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
|
|
qc.invalidateQueries({ queryKey: ['overview-market'] })
|
|
})
|
|
|
|
es.addEventListener('strategy_alert', (e: MessageEvent) => {
|
|
try {
|
|
const data = JSON.parse(e.data)
|
|
const alerts: StrategyAlertEvent[] = data.alerts || []
|
|
if (alerts.length > 0) {
|
|
handleAlerts(alerts)
|
|
// 实时刷新触发记录列表 + 监控中心徽标
|
|
qc.invalidateQueries({ queryKey: ['alerts'] })
|
|
qc.invalidateQueries({ queryKey: ['alerts-total'] })
|
|
}
|
|
} catch {
|
|
// 忽略解析错误
|
|
}
|
|
})
|
|
|
|
// 定时复盘流式进度: 后端到点生成时把 meta/delta/done 推来, 喂进 reviewStore
|
|
// 开着复盘页可看到「边生成边显示」, 切走再回来也能看到生成中/已生成
|
|
es.addEventListener('review_progress', (e: MessageEvent) => {
|
|
try {
|
|
const evt = JSON.parse(e.data)
|
|
feedReviewEvent(evt)
|
|
// done(后端已归档) → 刷新历史列表, 让新报告出现并可查看
|
|
if (evt.type === 'done') {
|
|
qc.invalidateQueries({ queryKey: QK.reviewReports })
|
|
}
|
|
} catch {
|
|
// 忽略解析错误
|
|
}
|
|
})
|
|
|
|
es.onerror = () => {
|
|
es.close()
|
|
esRef.current = null
|
|
const delay = getQueryConfig().sse.reconnectDelay
|
|
retryRef.current = setTimeout(connect, delay)
|
|
}
|
|
}
|
|
|
|
connect()
|
|
|
|
return () => {
|
|
clearTimeout(retryRef.current)
|
|
if (esRef.current) {
|
|
esRef.current.close()
|
|
esRef.current = null
|
|
}
|
|
}
|
|
}, [qc, handleAlerts])
|
|
}
|