2026-05-29 18:16:34 +08:00
|
|
|
|
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',
|
2026-05-31 00:16:46 +08:00
|
|
|
|
'monitor-config-updated',
|
|
|
|
|
|
'workspace-shortcut',
|
|
|
|
|
|
'viewport-style-updated'
|
2026-05-29 18:16:34 +08:00
|
|
|
|
] 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 }
|
|
|
|
|
|
}
|