Files
Migu2.0/frontends/apps/simple-platform-vue/src/composables/useProjectionStream.ts
T

200 lines
5.8 KiB
TypeScript
Raw Normal View History

import { onUnmounted, ref, type Ref } from 'vue'
/**
* 与 SimpleLite `/projection/stream`SSE)通信的轻量封装。
*
* 服务器以 `text/event-stream` 推送 JSON 行:
* ```
* event: car-state
* data: { "kind": "car-state", "tick": 123, "payload": { ... } }
* ```
*
* 离线 / 未启用时返回的 `connected = false`,调用方可继续用 3s 轮询兜底。
*/
export interface StreamEvent {
kind: string
tick?: number
payload?: unknown
}
export interface ProjectionStreamOptions {
/** SSE URL。默认走 axios baseURL `/api` + `/sl/projection/stream`。 */
url?: string
/** 挂载时自动 connect。默认 true。 */
autoConnect?: boolean
/** 断线后重连延时(ms)。默认 3000。 */
reconnectDelayMs?: number
/**
* 额外要订阅的命名事件。这些会和 {@link KNOWN_EVENT_KINDS} 合并后注册到 EventSource。
* 当后端新增 broadcast 事件名时,可不改动 composable,直接通过此参数补充。
*/
extraEventKinds?: readonly string[]
}
/**
* SimpleLite 当前后端会广播的全部 SSE 事件名。新增后端事件时务必同步追加,
* 否则 EventSource 拿不到该事件 —— 这是历史上 alarm / object-* 被静默丢弃的根因。
*/
export const KNOWN_EVENT_KINDS = [
'snapshot-tick',
'selection-detail',
'alarm',
'object-created',
'object-deleted',
'object-patched',
'object-batch-changed',
'pick-result',
'project-saved',
'project-loaded',
'car-state',
'mission-status',
'monitor-config-updated',
'workspace-shortcut',
'viewport-style-updated'
] as const
type Listener = (event: StreamEvent) => void
export function useProjectionStream(opts: ProjectionStreamOptions = {}) {
const apiBase = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
const defaultUrl = `${apiBase}/sl/projection/stream`
const url = opts.url ?? defaultUrl
const reconnectDelay = opts.reconnectDelayMs ?? 3000
// 与 api/* 模块统一走 VITE_USE_MOCK 开关(替代旧的 VITE_PROJECTION_MOCK)。
const isMock = import.meta.env.VITE_USE_MOCK === 'true'
const connected = ref(false)
const lastEvent: Ref<StreamEvent | null> = ref(null)
const listeners = new Set<Listener>()
let es: EventSource | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let mockTimer: ReturnType<typeof setInterval> | null = null
let mockTick = 0
let stopped = false
function connect() {
if (stopped) return
// Mock 模式:不发 EventSource(无后端可连),改用本地心跳广播
// `snapshot-tick`,让订阅方的"实时刷新"路径同样可被验证。
if (isMock) {
if (mockTimer) return
connected.value = true
mockTimer = setInterval(() => {
mockTick++
const event: StreamEvent = {
kind: 'snapshot-tick',
tick: mockTick,
payload: { reason: 'mock', tick: mockTick }
}
lastEvent.value = event
listeners.forEach((cb) => {
try { cb(event) } catch { /* swallow */ }
})
}, 5000)
return
}
if (es) return
try {
es = new EventSource(url)
} catch {
scheduleReconnect()
return
}
es.onopen = () => {
connected.value = true
}
es.onerror = () => {
connected.value = false
closeSocket()
scheduleReconnect()
}
es.onmessage = (msg) => {
handleEventLine(undefined, msg.data)
}
// 命名事件:EventSource 默认只把"无 event: 头"的消息派发给 onmessage
// 任何 `event: foo` 都必须通过 addEventListener('foo', ...) 才能收到。
// 必须覆盖 SimpleLite 后端可能广播的所有事件名 —— 漏一个就等于该事件被丢弃。
// 当前后端事件源:
// - ProjectionStreamModule: snapshot-tick (1Hz 心跳/计数)
// - SimpleUI / ReflectionApiController: selection-detail
// - AlarmStreamService: alarm
// - MapEditApiController: object-created / object-deleted / object-patched
// / object-batch-changed / pick-result
// / project-saved / project-loaded
// - 保留 car-state / mission-status 占位以兼容未来事件型推送
const allKinds = new Set<string>([...KNOWN_EVENT_KINDS, ...(opts.extraEventKinds ?? [])])
for (const kind of allKinds) {
es.addEventListener(kind, (msg: MessageEvent) => {
handleEventLine(kind, msg.data)
})
}
}
function handleEventLine(kindHint: string | undefined, raw: unknown) {
if (typeof raw !== 'string' || !raw) return
try {
const parsed = JSON.parse(raw)
const event: StreamEvent =
typeof parsed === 'object' && parsed !== null && 'kind' in parsed
? (parsed as StreamEvent)
: { kind: kindHint ?? 'message', payload: parsed }
lastEvent.value = event
listeners.forEach((cb) => {
try { cb(event) } catch { /* swallow */ }
})
} catch {
// ignore malformed payload
}
}
function closeSocket() {
if (es) {
try { es.close() } catch { /* ignore */ }
es = null
}
}
function scheduleReconnect() {
if (stopped) return
if (reconnectTimer) return
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connect()
}, reconnectDelay)
}
function disconnect() {
stopped = true
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (mockTimer) {
clearInterval(mockTimer)
mockTimer = null
}
closeSocket()
connected.value = false
}
function on(cb: Listener) { listeners.add(cb) }
function off(cb: Listener) { listeners.delete(cb) }
if (opts.autoConnect !== false) connect()
onUnmounted(() => {
disconnect()
listeners.clear()
})
return { connected, lastEvent, connect, disconnect, on, off }
}