feat: 迁入 MiGu.Server、平台前端与车辆列表 reflection 回退

从 Simple-FR 拆出 Platform.Server 并重命名为 MiGu.Server;frontends 源码与构建脚本迁入本仓库。地图监控在 projection/cars 失败或为空时回退 reflection 车辆列表;Simple 仓库已移除旧 Platform.Server。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-05-29 18:16:34 +08:00
co-authored by Cursor
parent 804aa68ade
commit 42978930ca
280 changed files with 30046 additions and 8 deletions
@@ -0,0 +1,106 @@
import type { BatchOp } from '@/api/mapEdit'
/**
* 对齐算法:纯前端计算每个选中对象的新坐标,再以 batch ops(patch)回写到反射 API。
* 仅支持有 (x, y) 坐标的对象(site / image / text / model 等 UIHelper 子类);
* track 是端点连线,没有自己的坐标,alignment 不影响 track。
*/
export interface AlignTarget {
kind: string
id: number
x: number
y: number
width?: number
height?: number
}
export type AlignMode =
| 'left'
| 'right'
| 'top'
| 'bottom'
| 'centerH'
| 'centerV'
| 'center'
| 'distributeH'
| 'distributeV'
| 'toFirst'
| 'toLast'
/**
* 根据对齐模式计算 patch ops。返回值为 mapEdit.batch 直接消费的 BatchOp 数组。
* 输入要求至少 2 个对象(distribute 需要 ≥ 3 才有意义)。
*/
export function buildAlignOps(targets: AlignTarget[], mode: AlignMode): BatchOp[] {
if (targets.length < 2) return []
const ops: BatchOp[] = []
const xs = targets.map((t) => t.x)
const ys = targets.map((t) => t.y)
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minY = Math.min(...ys)
const maxY = Math.max(...ys)
const avgX = xs.reduce((a, b) => a + b, 0) / xs.length
const avgY = ys.reduce((a, b) => a + b, 0) / ys.length
switch (mode) {
case 'left':
for (const t of targets) ops.push(patch(t, { x: minX }))
break
case 'right':
for (const t of targets) ops.push(patch(t, { x: maxX }))
break
case 'top':
// 工程坐标 y 向上为正:top 取 max
for (const t of targets) ops.push(patch(t, { y: maxY }))
break
case 'bottom':
for (const t of targets) ops.push(patch(t, { y: minY }))
break
case 'centerH':
for (const t of targets) ops.push(patch(t, { x: avgX }))
break
case 'centerV':
for (const t of targets) ops.push(patch(t, { y: avgY }))
break
case 'center':
for (const t of targets) ops.push(patch(t, { x: avgX, y: avgY }))
break
case 'distributeH': {
const sorted = [...targets].sort((a, b) => a.x - b.x)
const step = (sorted[sorted.length - 1]!.x - sorted[0]!.x) / (sorted.length - 1)
sorted.forEach((t, i) => {
const x = sorted[0]!.x + step * i
if (Math.abs(x - t.x) > 0.5) ops.push(patch(t, { x }))
})
break
}
case 'distributeV': {
const sorted = [...targets].sort((a, b) => a.y - b.y)
const step = (sorted[sorted.length - 1]!.y - sorted[0]!.y) / (sorted.length - 1)
sorted.forEach((t, i) => {
const y = sorted[0]!.y + step * i
if (Math.abs(y - t.y) > 0.5) ops.push(patch(t, { y }))
})
break
}
case 'toFirst': {
const ref = targets[0]!
for (const t of targets.slice(1)) ops.push(patch(t, { x: ref.x, y: ref.y }))
break
}
case 'toLast': {
const ref = targets[targets.length - 1]!
for (const t of targets.slice(0, -1)) ops.push(patch(t, { x: ref.x, y: ref.y }))
break
}
}
return ops
}
function patch(t: AlignTarget, data: Record<string, unknown>): BatchOp {
return { action: 'patch', kind: t.kind, id: t.id, data }
}
@@ -0,0 +1,163 @@
import type { BatchOp, CreateSitePayload } from '@/api/mapEdit'
/**
* 站点批量生成器:横向 / 纵向 / 矩阵 / 沿路径 / 环形阵列。
* 每个函数返回 batch ops 列表,调用方再 `mapEditApi.batch(ops)` 一次落地,
* 同时把这条命令塞进 useHistory 栈实现整体撤销。
*
* 坐标单位毫米;沿路径采样用直线段近似(贝塞尔 / 弧线后续可改为参数化采样)。
*/
export interface LinearGenOpts {
count: number
layer?: string
namePrefix?: string
}
/** 横向:从 (x1, y) → (x2, y) 等距生成 count 个站点(含两端)。 */
export function genLinearH(x1: number, x2: number, y: number, opts: LinearGenOpts): BatchOp[] {
return genLinear(x1, y, x2, y, opts)
}
export function genLinearV(x: number, y1: number, y2: number, opts: LinearGenOpts): BatchOp[] {
return genLinear(x, y1, x, y2, opts)
}
function genLinear(x1: number, y1: number, x2: number, y2: number, opts: LinearGenOpts): BatchOp[] {
const ops: BatchOp[] = []
const n = Math.max(2, Math.floor(opts.count))
for (let i = 0; i < n; i++) {
const t = i / (n - 1)
const data: CreateSitePayload = {
x: x1 + (x2 - x1) * t,
y: y1 + (y2 - y1) * t,
name: `${opts.namePrefix ?? 'S'}_${i + 1}`,
layer: opts.layer
}
ops.push({ action: 'create', kind: 'site', data: data as unknown as Record<string, unknown> })
}
return ops
}
export interface MatrixGenOpts {
rows: number
cols: number
layer?: string
namePrefix?: string
}
export function genMatrix(x1: number, y1: number, x2: number, y2: number, opts: MatrixGenOpts): BatchOp[] {
const ops: BatchOp[] = []
const rows = Math.max(1, Math.floor(opts.rows))
const cols = Math.max(1, Math.floor(opts.cols))
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const tx = cols === 1 ? 0 : c / (cols - 1)
const ty = rows === 1 ? 0 : r / (rows - 1)
ops.push({
action: 'create',
kind: 'site',
data: {
x: x1 + (x2 - x1) * tx,
y: y1 + (y2 - y1) * ty,
name: `${opts.namePrefix ?? 'M'}_${r + 1}_${c + 1}`,
layer: opts.layer
} as unknown as Record<string, unknown>
})
}
}
return ops
}
export interface CircularGenOpts {
count: number
layer?: string
namePrefix?: string
startAngleDeg?: number
endAngleDeg?: number
}
export function genCircular(cx: number, cy: number, radius: number, opts: CircularGenOpts): BatchOp[] {
const ops: BatchOp[] = []
const n = Math.max(1, Math.floor(opts.count))
const start = ((opts.startAngleDeg ?? 0) * Math.PI) / 180
const end = ((opts.endAngleDeg ?? 360) * Math.PI) / 180
const span = end - start
for (let i = 0; i < n; i++) {
// 闭合环:end-start = 2π 时不要把第 0 个与第 n 个重叠
const denom = Math.abs(span - Math.PI * 2) < 1e-6 ? n : n - 1
const t = denom === 0 ? 0 : i / denom
const a = start + span * t
ops.push({
action: 'create',
kind: 'site',
data: {
x: cx + Math.cos(a) * radius,
y: cy + Math.sin(a) * radius,
name: `${opts.namePrefix ?? 'C'}_${i + 1}`,
layer: opts.layer
} as unknown as Record<string, unknown>
})
}
return ops
}
export interface AlongPathGenOpts {
/** 路径折线点数组:[x, y, x, y, ...]。 */
polyline: number[]
/** 站点之间间距 mm,过密会自动按总长度调整。 */
stepMm: number
layer?: string
namePrefix?: string
}
export function genAlongPath(opts: AlongPathGenOpts): BatchOp[] {
const pts: number[][] = []
const p = opts.polyline
if (p.length < 4) return []
for (let i = 0; i < p.length; i += 2) pts.push([p[i]!, p[i + 1]!])
const seg = [] as { x1: number; y1: number; x2: number; y2: number; len: number }[]
let total = 0
for (let i = 0; i < pts.length - 1; i++) {
const [x1, y1] = pts[i]!
const [x2, y2] = pts[i + 1]!
const len = Math.hypot(x2 - x1, y2 - y1)
seg.push({ x1: x1!, y1: y1!, x2: x2!, y2: y2!, len })
total += len
}
if (total < 1) return []
const step = Math.max(1, opts.stepMm)
const ops: BatchOp[] = []
let traveled = 0
let sIdx = 0
let nameIdx = 1
while (traveled <= total + 1e-3) {
while (sIdx < seg.length && traveled > (seg[sIdx]!.len + accumulatedUpTo(seg, sIdx))) sIdx++
if (sIdx >= seg.length) break
const s = seg[sIdx]!
const baseTraveled = accumulatedUpTo(seg, sIdx)
const local = traveled - baseTraveled
const t = s.len === 0 ? 0 : local / s.len
ops.push({
action: 'create',
kind: 'site',
data: {
x: s.x1 + (s.x2 - s.x1) * t,
y: s.y1 + (s.y2 - s.y1) * t,
name: `${opts.namePrefix ?? 'P'}_${nameIdx}`,
layer: opts.layer
} as unknown as Record<string, unknown>
})
nameIdx++
traveled += step
}
return ops
}
function accumulatedUpTo(seg: { len: number }[], idx: number) {
let s = 0
for (let i = 0; i < idx; i++) s += seg[i]!.len
return s
}
@@ -0,0 +1,43 @@
import { onBeforeUnmount, onMounted } from 'vue'
/**
* 与 Workspace3D iframeSimpleLite WebTerminal)的 postMessage 双向桥。
*
* 当前 SimpleLite WebTerminal 默认会通过 window.postMessage 抛出 `workspace.pick`
* 与 `workspace.select` 事件给父窗口(Workspace3D.vue 收到后再 emit 给业务页)。
* 反向的 vue→iframe 通道(设置工具、应用对齐等)走 SimpleLite 反射 APIHTTP/SSE),
* 这里的桥只承担**坐标 / 选中 / hover** 三类 iframe→vue 数据。
*/
export interface CanvasPickPayload { x: number; y: number; siteId?: number }
export interface CanvasSelectPayload { names: string[] }
export interface CanvasMousePayload { x: number; y: number }
export interface CanvasBridgeOptions {
onPick?: (p: CanvasPickPayload) => void
onSelect?: (p: CanvasSelectPayload) => void
onMouse?: (p: CanvasMousePayload) => void
}
export function useCanvasBridge(opts: CanvasBridgeOptions = {}) {
function onMessage(ev: MessageEvent) {
const msg = ev?.data
if (!msg || typeof msg !== 'object' || typeof (msg as { type?: string }).type !== 'string') return
const type = (msg as { type: string }).type
const payload = (msg as { payload?: unknown }).payload
if (type === 'workspace.pick' && opts.onPick && payload && typeof payload === 'object' && 'x' in payload && 'y' in payload) {
const p = payload as CanvasPickPayload
opts.onPick({ x: Number(p.x), y: Number(p.y), siteId: typeof p.siteId === 'number' ? p.siteId : undefined })
} else if (type === 'workspace.select' && opts.onSelect && Array.isArray(payload)) {
opts.onSelect({ names: (payload as string[]) })
} else if (type === 'workspace.mouse' && opts.onMouse && payload && typeof payload === 'object' && 'x' in payload && 'y' in payload) {
const p = payload as CanvasMousePayload
opts.onMouse({ x: Number(p.x), y: Number(p.y) })
}
}
onMounted(() => window.addEventListener('message', onMessage))
onBeforeUnmount(() => window.removeEventListener('message', onMessage))
}
@@ -0,0 +1,56 @@
import { ref } from 'vue'
import type { SelectionItem } from './useSelection'
/**
* 编辑器剪贴板:保存最近一次「复制」操作的对象快照(含字段值),
* 用于「粘贴 (Ctrl+V)」与「复制字段 (Copy Fields)」。
*
* 粘贴策略:调用方在拿到目标坐标后,用 mapEditApi.batch 创建副本(带偏移)。
* 复制字段:调用方调 mapEditApi.copyFieldsTo 把指定字段名写到目标对象(们)。
*
* 注意:剪贴板里保存的是对象的"逻辑快照",不是 DOM 文本剪贴板。
*/
export interface ClipboardSnapshot {
items: Array<{
kind: string
sourceId: number
typeName: string
/** 对象的几何 / 样式字段(含 x, y 用于偏移粘贴)。 */
fields: Record<string, string>
}>
fieldNames: string[]
}
export function useClipboard() {
const data = ref<ClipboardSnapshot | null>(null)
function copy(items: SelectionItem[], allFields: Record<number, Record<string, string>>) {
if (items.length === 0) {
data.value = null
return
}
const fieldNamesSet = new Set<string>()
const snapshot: ClipboardSnapshot = {
items: items.map((it) => {
const f = allFields[it.id] ?? {}
Object.keys(f).forEach((k) => fieldNamesSet.add(k))
return {
kind: it.kind,
sourceId: it.id,
typeName: it.typeName,
fields: f
}
}),
fieldNames: []
}
snapshot.fieldNames = [...fieldNamesSet]
data.value = snapshot
}
function clear() {
data.value = null
}
return { data, copy, clear }
}
@@ -0,0 +1,180 @@
import { ref, computed } from 'vue'
/**
* 地图编辑器当前激活工具的状态机。每次工具切换会把 EditToolRail 按钮高亮、
* 状态栏文字与 Canvas iframe 内的鼠标行为联动。
*
* 工具切换有两个层面:
* - **前端切换**:仅影响 Vue 端的按钮高亮 + 状态栏 + 下一次画布点击的本地处理(如 pick 后调反射 API 创建对象)。
* - **后端切换**:通过反射 API `/execute/...` 触发 SimpleLite 的 CAD action(吸附 / 对齐 / 框选等)。
*
* 简化起见:本 composable 只负责前端状态,CAD action 由调用方决定何时触发。
*/
export type EditToolId =
// 选择组
| 'select.single'
| 'select.rect'
| 'select.lasso'
| 'select.byType.site'
| 'select.byType.bezier'
| 'select.byType.polyline'
| 'select.byType.arc'
| 'select.byType.decor'
| 'select.byType.currentLayer'
| 'select.invert'
| 'select.clear'
// 绘制组
| 'draw.site'
| 'draw.sites.continuous'
| 'draw.track.polyline'
| 'draw.track.bezier'
| 'draw.track.arc'
| 'draw.track.nurbs'
| 'draw.decor.line'
| 'draw.decor.rect'
| 'draw.decor.circle'
| 'draw.decor.text'
| 'draw.image'
| 'draw.model'
// 变换组
| 'transform.move'
| 'transform.rotate'
| 'transform.scale'
| 'transform.duplicate'
| 'transform.copy'
| 'transform.paste'
| 'transform.delete'
| 'transform.copyFields'
// 对齐组
| 'align.left'
| 'align.right'
| 'align.top'
| 'align.bottom'
| 'align.centerH'
| 'align.centerV'
| 'align.center'
| 'align.distributeH'
| 'align.distributeV'
| 'align.toFirst'
| 'align.toLast'
// 批量生成
| 'batch.linearH'
| 'batch.linearV'
| 'batch.matrix'
| 'batch.alongPath'
| 'batch.circular'
// 吸附 / 辅助
| 'snap.grid'
| 'snap.object'
| 'snap.endpoint'
| 'snap.midpoint'
| 'show.controlPoints'
| 'show.controlHandles'
export interface SnapState {
grid: boolean
gridSizeMm: number
object: boolean
endpoint: boolean
midpoint: boolean
toAngleDeg?: number
}
export function useEditTool() {
const activeTool = ref<EditToolId>('transform.move')
const snap = ref<SnapState>({ grid: false, gridSizeMm: 100, object: true, endpoint: true, midpoint: false, toAngleDeg: undefined })
const showControlPoints = ref(true)
const showControlHandles = ref(true)
/** 当前画布行为类别(用于状态栏 / 鼠标光标 hint)。 */
const toolCategory = computed<'select' | 'draw' | 'transform' | 'align' | 'batch' | 'snap' | 'show'>(() => {
const id = activeTool.value
if (id.startsWith('select.')) return 'select'
if (id.startsWith('draw.')) return 'draw'
if (id.startsWith('transform.')) return 'transform'
if (id.startsWith('align.')) return 'align'
if (id.startsWith('batch.')) return 'batch'
if (id.startsWith('snap.')) return 'snap'
return 'show'
})
/** 当前工具的人类可读名称(状态栏展示)。 */
const toolLabel = computed(() => {
const map: Record<EditToolId, string> = {
'select.single': '单选',
'select.rect': '矩形框选',
'select.lasso': '圈选',
'select.byType.site': '按类型选 - 站点',
'select.byType.bezier': '按类型选 - 贝塞尔',
'select.byType.polyline': '按类型选 - 折线',
'select.byType.arc': '按类型选 - 弧',
'select.byType.decor': '按类型选 - 装饰物',
'select.byType.currentLayer': '按类型选 - 当前图层',
'select.invert': '反选',
'select.clear': '清空选中',
'draw.site': '添加站点',
'draw.sites.continuous': '连续添加站点',
'draw.track.polyline': '添加折线路径',
'draw.track.bezier': '添加贝塞尔路径',
'draw.track.arc': '添加弧形路径',
'draw.track.nurbs': '添加 NURBS 路径',
'draw.decor.line': '添加线段',
'draw.decor.rect': '添加矩形',
'draw.decor.circle': '添加圆',
'draw.decor.text': '添加文本',
'draw.image': '插入图片',
'draw.model': '插入 3D 模型',
'transform.move': '移动',
'transform.rotate': '旋转',
'transform.scale': '缩放',
'transform.duplicate': '复制副本',
'transform.copy': '复制',
'transform.paste': '粘贴',
'transform.delete': '删除',
'transform.copyFields': '复制字段',
'align.left': '左对齐',
'align.right': '右对齐',
'align.top': '上对齐',
'align.bottom': '下对齐',
'align.centerH': '水平居中',
'align.centerV': '垂直居中',
'align.center': '整体居中',
'align.distributeH': '水平等距',
'align.distributeV': '垂直等距',
'align.toFirst': '对齐到第一个',
'align.toLast': '对齐到最后一个',
'batch.linearH': '横向间隔生成',
'batch.linearV': '纵向间隔生成',
'batch.matrix': '矩阵生成',
'batch.alongPath': '沿路径采样生成',
'batch.circular': '环形阵列',
'snap.grid': '网格吸附',
'snap.object': '对象吸附',
'snap.endpoint': '端点吸附',
'snap.midpoint': '中点吸附',
'show.controlPoints': '显示控制点',
'show.controlHandles': '显示控制柄'
}
return map[activeTool.value] ?? activeTool.value
})
function setTool(id: EditToolId) {
activeTool.value = id
}
function toggleSnap(key: 'grid' | 'object' | 'endpoint' | 'midpoint') {
snap.value = { ...snap.value, [key]: !snap.value[key] }
}
return {
activeTool,
snap,
showControlPoints,
showControlHandles,
toolCategory,
toolLabel,
setTool,
toggleSnap
}
}
@@ -0,0 +1,102 @@
import { computed, ref } from 'vue'
/**
* 编辑器命令栈:所有反射 API 写操作都包装为 `EditCommand`,做 / 撤销 双向。
*
* 设计要点:
* - `apply` / `revert` 都是异步,让命令内部 await 反射 API。
* - `apply` 内部可以记录新建对象的 id(如 createSite 返回 id),在 closure 里保存供 `revert` 使用。
* - 栈深度默认 20(撤销最多 20 步),覆盖创建站点 / 配置站点字段 / 创建路径 / 删除 / 对齐 /
* 字段复制 / 批量生成 / AI 落地 等所有走 `run()` 的写操作;超出旧命令直接丢弃。
* - 批处理(如「矩阵生成」一次调用产生 N 条 create)会被包装为单个 `CompositeCommand`
* 一次撤销整体回退。
*
* 注意:本栈只跟踪通过 `do(...)` 提交的命令;如果直接调反射 API(不通过这里),栈不感知。
* 推荐所有编辑器写操作通过此 composable 暴露的命令工厂触发。
*/
export interface EditCommand {
/** 命令的人类可读描述(用于撤销提示气泡)。 */
label: string
/** 正向执行。可能产生副作用(创建/删除/修改对象,调反射 API)。 */
apply: () => Promise<void> | void
/** 反向撤销。要保证幂等:多次 revert 不应崩。 */
revert: () => Promise<void> | void
}
export interface UseHistoryOptions {
maxDepth?: number
onChange?: () => void
}
export function useHistory(opts: UseHistoryOptions = {}) {
const maxDepth = opts.maxDepth ?? 20
const undoStack = ref<EditCommand[]>([])
const redoStack = ref<EditCommand[]>([])
const busy = ref(false)
const canUndo = computed(() => undoStack.value.length > 0 && !busy.value)
const canRedo = computed(() => redoStack.value.length > 0 && !busy.value)
async function run(cmd: EditCommand) {
if (busy.value) return
busy.value = true
try {
await cmd.apply()
undoStack.value = [...undoStack.value, cmd].slice(-maxDepth)
redoStack.value = []
opts.onChange?.()
} finally {
busy.value = false
}
}
async function undo() {
if (!canUndo.value) return
const cmd = undoStack.value[undoStack.value.length - 1]!
busy.value = true
try {
await cmd.revert()
undoStack.value = undoStack.value.slice(0, -1)
redoStack.value = [...redoStack.value, cmd].slice(-maxDepth)
opts.onChange?.()
} finally {
busy.value = false
}
}
async function redo() {
if (!canRedo.value) return
const cmd = redoStack.value[redoStack.value.length - 1]!
busy.value = true
try {
await cmd.apply()
redoStack.value = redoStack.value.slice(0, -1)
undoStack.value = [...undoStack.value, cmd].slice(-maxDepth)
opts.onChange?.()
} finally {
busy.value = false
}
}
function clear() {
undoStack.value = []
redoStack.value = []
opts.onChange?.()
}
/** 组合命令:把一组 sub-commands 当作整体撤销 / 重做。 */
function composite(label: string, subs: EditCommand[]): EditCommand {
return {
label,
apply: async () => { for (const s of subs) await s.apply() },
revert: async () => {
// 反向逐条 revert,确保依赖序无误(例如先删 track 再删 site
for (let i = subs.length - 1; i >= 0; i--) await subs[i]!.revert()
}
}
}
return { undoStack, redoStack, busy, canUndo, canRedo, run, undo, redo, clear, composite }
}
@@ -0,0 +1,103 @@
import { onBeforeUnmount, onMounted } from 'vue'
import { useProjectionStream, type StreamEvent } from './useProjectionStream'
/**
* 编辑器 / 监控页对 SimpleLite SSE 的事件订阅封装,把以下 5 类事件转给业务回调:
*
* - `alarm` (来自 AlarmStreamService):报警新增/更新/解除
* - `object-created` (来自 MapEditApiController):对象创建后
* - `object-deleted` (来自 MapEditApiController):对象删除后
* - `object-patched` (来自 MapEditApiController):对象字段被 patch 后
* - `object-batch-changed`(来自 batch / ai 调用):一组对象变化,建议直接整页重拉
* - `pick-result` (来自 /pick):拾取会话完成
*
* 业务页只用关心自己感兴趣的事件回调,其它走默认忽略。
*/
export interface AlarmEvent {
action: 'raise' | 'update' | 'clear'
carId: number
carName: string
info?: string
x?: number
y?: number
timestamp: string
}
export interface ObjectChangeEvent {
kind: string
id: number
typeName?: string
source?: string
}
export interface PickResultEvent {
x: number
y: number
siteId: number
}
export interface SelectionDetailEvent {
/** 主选中 kind (site|track|car|null);当多选时取首个非空集合的 kind。 */
kind: 'site' | 'track' | 'car' | null
/** 主选中 id;多选时取首个。 */
id: number
siteIds: number[]
trackIds: number[]
carIds: number[]
/** 完整对象名列表,例如 ["UISite-12", "UITrack-7", "Car-3"]。 */
names: string[]
}
export interface MapEditStreamHandlers {
onAlarm?: (e: AlarmEvent) => void
onObjectCreated?: (e: ObjectChangeEvent) => void
onObjectDeleted?: (e: ObjectChangeEvent) => void
onObjectPatched?: (e: ObjectChangeEvent) => void
onObjectBatchChanged?: (e: { count: number; source?: string }) => void
onPickResult?: (e: PickResultEvent) => void
onSelectionDetail?: (e: SelectionDetailEvent) => void
}
export function useMapEditStream(handlers: MapEditStreamHandlers) {
const stream = useProjectionStream({ autoConnect: false })
function dispatch(ev: StreamEvent) {
const payload = ev?.payload as Record<string, unknown> | undefined
if (!payload) return
switch (ev.kind) {
case 'alarm':
handlers.onAlarm?.(payload as unknown as AlarmEvent)
break
case 'object-created':
handlers.onObjectCreated?.(payload as unknown as ObjectChangeEvent)
break
case 'object-deleted':
handlers.onObjectDeleted?.(payload as unknown as ObjectChangeEvent)
break
case 'object-patched':
handlers.onObjectPatched?.(payload as unknown as ObjectChangeEvent)
break
case 'object-batch-changed':
handlers.onObjectBatchChanged?.(payload as unknown as { count: number; source?: string })
break
case 'pick-result':
handlers.onPickResult?.(payload as unknown as PickResultEvent)
break
case 'selection-detail':
handlers.onSelectionDetail?.(payload as unknown as SelectionDetailEvent)
break
}
}
onMounted(() => {
stream.on(dispatch)
stream.connect()
})
onBeforeUnmount(() => {
stream.off(dispatch)
stream.disconnect()
})
return { connected: stream.connected }
}
@@ -0,0 +1,197 @@
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'
] 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 }
}
@@ -0,0 +1,84 @@
import { computed, ref } from 'vue'
import type { ReflectionObject, ReflectionKind } from '@/api/reflection'
/**
* 编辑器内的多选集合 + 类型过滤工具。
* 选中集是 `(kind, id)` 二元组集合:用 `${kind}:${id}` 作 key 去重。
*
* 与 SimpleLite 后端的同步:单选时把 selection 同步给 ReflectionApi(高亮 3D 场景),
* 多选不同步(高亮不展开是因为反射 API 当前只支持单选;如需多选高亮以后再扩展)。
*/
export interface SelectionItem extends ReflectionObject {
kind: ReflectionKind
}
function keyOf(kind: string, id: number) {
return `${kind}:${id}`
}
export function useSelection() {
const items = ref<SelectionItem[]>([])
const lastClickedKey = ref<string | null>(null)
const ids = computed(() => items.value.map((x) => x.id))
const count = computed(() => items.value.length)
function has(kind: string, id: number) {
const k = keyOf(kind, id)
return items.value.some((x) => keyOf(x.kind, x.id) === k)
}
function add(item: SelectionItem) {
if (has(item.kind, item.id)) return
items.value = [...items.value, item]
lastClickedKey.value = keyOf(item.kind, item.id)
}
function remove(kind: string, id: number) {
const k = keyOf(kind, id)
items.value = items.value.filter((x) => keyOf(x.kind, x.id) !== k)
if (lastClickedKey.value === k) lastClickedKey.value = null
}
function toggle(item: SelectionItem) {
if (has(item.kind, item.id)) remove(item.kind, item.id)
else add(item)
}
function set(list: SelectionItem[]) {
items.value = list
lastClickedKey.value = list.length > 0 ? keyOf(list[list.length - 1]!.kind, list[list.length - 1]!.id) : null
}
function clear() {
items.value = []
lastClickedKey.value = null
}
/** 按类型过滤当前选中:用于「按类型选」工具,例如只保留 site 子集。 */
function filterByKind(kind: string) {
items.value = items.value.filter((x) => x.kind === kind)
}
/** 单选 - 替换;适合 single-pick 工具点击时使用。 */
function selectSingle(item: SelectionItem) {
items.value = [item]
lastClickedKey.value = keyOf(item.kind, item.id)
}
return {
items,
ids,
count,
lastClickedKey,
has,
add,
remove,
toggle,
set,
clear,
filterByKind,
selectSingle
}
}