增强地图编辑 CAD 能力与反射对象管理体验。

补充对齐/批量建站 API,并优化反射面板、历史栈与字段管理相关交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-07-26 11:32:30 +08:00
co-authored by Cursor
parent b1dd4f9005
commit 8299c3dc5c
18 changed files with 609 additions and 231 deletions
@@ -122,8 +122,17 @@ export interface AssetUploadResult {
url: string
}
export type SelectTrackMode = 'all' | 'straight' | 'curve'
export interface ViewFilterSnapshot {
selectFilter: { sites: boolean; tracks: boolean; cars: boolean; decor: boolean }
selectFilter: {
sites: boolean
tracks: boolean
cars: boolean
decor: boolean
/** 路径子类型:all=全部;straight=仅 UITrackcurve=贝塞尔/弧/NURBS */
trackMode?: SelectTrackMode
}
alignSnap: { sites: boolean; cars: boolean; tracks: boolean }
showViewport: { sceneLabels: boolean; scenePrimitives: boolean; cars: boolean }
}
@@ -195,11 +204,68 @@ export const mapEditApi = {
batch: (ops: BatchOp[]) =>
unwrap<{ count: number; results: unknown[] }>(http.post(`${BASE}/objects/batch`, { ops })),
/**
* CAD 相对包围盒对齐(SimpleLite CadAlignService)。
* mode: left|right|top|bottom|centerH|centerV|center|distributeH|distributeV
*/
cadAlign: (mode: string, targets: Array<{ kind: string; id: number }>) =>
unwrap<{
mode: string
count: number
previous: Array<{ kind: string; id: number; x: number; y: number }>
updated: Array<{ kind: string; id: number; x: number; y: number }>
}>(http.post(`${BASE}/cad/align`, { mode, targets })),
/** 横向/纵向等距建站(CadBatchGenerateService.PlanLinear)。 */
cadBatchLinear: (p: {
horizontal: boolean
x1: number
y1: number
x2: number
y2: number
count: number
layer?: string
namePrefix?: string
}) =>
unwrap<{ count: number; created: Array<{ kind: string; id: number }> }>(
http.post(`${BASE}/cad/batch-linear`, p)
),
/** 矩阵建站(CadBatchGenerateService.PlanMatrix)。 */
cadBatchMatrix: (p: {
x1: number
y1: number
x2: number
y2: number
rows: number
cols: number
layer?: string
namePrefix?: string
}) =>
unwrap<{ count: number; created: Array<{ kind: string; id: number }> }>(
http.post(`${BASE}/cad/batch-matrix`, p)
),
copyFieldsTo: (kind: MapEditKind | string, id: number, fieldNames: string[], targets: Array<{ kind: string; id: number }>) =>
unwrap<{ copied: number }>(http.post(`${BASE}/objects/${kind}/${id}/fields/copy-to`, { fieldNames, targets })),
// 拾取 / 仪表盘
pick: () => unwrap<PickResult>(http.post(`${BASE}/pick`)),
/** 场景已有自定义字段键(Ctrl+I /「复制字段」弹窗候选)。 */
sceneFieldKeys: () =>
unwrap<{ keys: string[] }>(http.get(`${BASE}/scene-field-keys`)),
/** 对多个目标批量写入同一字段值(对齐桌面 Ctrl+I)。 */
batchSetField: (key: string, value: string, targets: Array<{ kind: string; id: number }>) =>
unwrap<{ affected: number }>(http.post(`${BASE}/batch-set-field`, { key, value, targets })),
/** 端点落在给定站点上的路径(删站点会级联删除;撤销前需一并快照)。 */
tracksTouchingSites: (siteIds: number[]) =>
unwrap<{ tracks: Array<{ id: number; typeName: string; siteA: number; siteB: number }> }>(
http.post(`${BASE}/tracks-touching-sites`, { siteIds })
),
// 拾取 / 仪表盘。snap=false 时落点用原始鼠标坐标(文本/图片/站点);布线取端点默认 true。
pick: (opts?: { snap?: boolean }) =>
unwrap<PickResult>(http.post(`${BASE}/pick`, { snap: opts?.snap ?? true })),
dashboardSummary: () =>
unwrap<DashboardSummary>(http.get(`${BASE}/dashboard/summary`)),
@@ -4,11 +4,16 @@ import type { Site, Track } from '@/types/map'
import type { Car, CarState } from '@/types/car'
import type { Mission, MissionStatus } from '@/types/mission'
import { mockSites, mockTracks, mockCars, mockMissions } from '@/mock/server'
import { applyRuntimeEnrichment, deriveCarState } from '@/utils/carRuntime'
/** 设为 true 时使用本地 Mock;默认走 YARP → SimpleLite :8222。
* 与 auth.ts / config.ts / ops.ts 统一走 VITE_USE_MOCK 开关(替代旧的 VITE_PROJECTION_MOCK)。 */
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
/** 车体 status 缓存:电量 + 告警信号,避免 3s 轮询打爆 reflection。 */
const STATUS_CACHE_TTL_MS = 4000
const statusCache = new Map<number, { at: number; rows: Array<{ key: string; value: string }> | null }>()
export async function listSites(): Promise<Site[]> {
if (MOCK) return mockSites()
const { data } = await http.get<Site[]>('/sl/projection/sites')
@@ -22,14 +27,7 @@ export async function listTracks(): Promise<Track[]> {
}
function mapStatusToCarState(status?: string | null): CarState {
if (!status) return 'idle'
const s = status.toLowerCase()
if (/fault|error|failed|故障|异常|失联|超时|检修/.test(s)) return 'fault'
if (/charg|充电/.test(s)) return 'charging'
if (/pause|暂停|挂起/.test(s)) return 'paused'
if (/offline|离线/.test(s)) return 'offline'
if (/run|busy|working|运行|工作|执行|忙/.test(s)) return 'running'
return 'idle'
return deriveCarState({ state: 'idle', lstatus: status ?? undefined }, null)
}
function mapStatusToMissionStatus(status?: string | null): MissionStatus {
@@ -53,10 +51,11 @@ function carFromReflection(row: ReflectionObject): Car {
x: 0,
y: 0,
theta: 0,
batterySoc: 0.8,
batterySoc: 0,
state: mapStatusToCarState(row.status),
lastUpdate: new Date().toISOString(),
group: row.layer ?? undefined
group: row.layer ?? undefined,
lstatus: row.status ?? undefined
}
}
@@ -82,17 +81,52 @@ async function listMissionsFromReflection(): Promise<Mission[]> {
return rows.map(missionFromReflection)
}
async function loadCarStatus(carId: number): Promise<Array<{ key: string; value: string }> | null> {
const cached = statusCache.get(carId)
const now = Date.now()
if (cached && now - cached.at < STATUS_CACHE_TTL_MS) return cached.rows
try {
const status = await reflectionApi.getStatus('car', carId)
const rows = (status ?? []).map((r) => ({ key: r.key, value: r.value }))
statusCache.set(carId, { at: now, rows })
return rows
} catch {
statusCache.set(carId, { at: now, rows: cached?.rows ?? null })
return cached?.rows ?? null
}
}
/**
* 纠正投影里写死的 batterySoc=0.8,以及把「正常但未初始化」误标成 fault 的 state。
* 数据源:反射 status(车体_Soc / AlarmLevel / driveStatus)。
*/
async function enrichCarsRuntime(cars: Car[]): Promise<Car[]> {
return Promise.all(
cars.map(async (car) => {
const id = car.rawId ?? Number(String(car.id).replace(/^C/i, ''))
// 无车体 status 时也先按 lstatus 纠一次(「正常但未初始化」→ idle)
if (!Number.isFinite(id) || id <= 0) {
return applyRuntimeEnrichment(car, null)
}
const rows = await loadCarStatus(id)
return applyRuntimeEnrichment(car, rows)
})
)
}
/** 优先走 SimpleLite /projection/cars502 或空列表时回退 reflection 对象列表(与 3D 场景同源)。 */
export async function listCars(): Promise<Car[]> {
if (MOCK) return mockCars()
try {
const { data } = await http.get<Car[]>('/sl/projection/cars')
if (Array.isArray(data) && data.length > 0) return data
if (Array.isArray(data) && data.length > 0) {
return enrichCarsRuntime(data)
}
const fallback = await listCarsFromReflection()
if (fallback.length > 0) return fallback
return Array.isArray(data) ? data : []
if (fallback.length > 0) return enrichCarsRuntime(fallback)
return Array.isArray(data) ? enrichCarsRuntime(data) : []
} catch {
return listCarsFromReflection()
return enrichCarsRuntime(await listCarsFromReflection())
}
}
@@ -1,4 +1,5 @@
import http from './http'
import { ElMessageBox } from 'element-plus'
import {
mockReflectionAssemblies,
mockReflectionBundle,
@@ -64,6 +65,8 @@ export interface ReflectionMethod {
returnType: string
hasParams: boolean
params: ReflectionParam[]
requiresPlatformConfirm?: boolean
confirmMessage?: string | null
}
export interface ReflectionObject {
@@ -225,30 +228,94 @@ export function emptyMonitorVisibilityMap(): MonitorVisibilityMap {
// 单独保留命名是为了表达"业务含义不同"——可勾选全集 vs 已勾选白名单。
export const emptyMonitorAvailableMap = emptyMonitorVisibilityMap
export class ReflectionApiError extends Error {
code: number
data: unknown
constructor(message: string, code: number, data?: unknown) {
super(message)
this.name = 'ReflectionApiError'
this.code = code
this.data = data ?? null
}
}
export interface ReflectionExecuteResult {
returnValue?: string | null
accepted?: boolean
completed?: boolean
}
/** 根据 execute 返回区分「已完成」与「已受理(后台继续)」文案。 */
export function formatReflectionExecuteMessage(
label: string,
result: ReflectionExecuteResult
): string {
if (result.returnValue) return `已执行:${result.returnValue}`
if (result.accepted && result.completed === false) {
return `已受理:${label}(后台继续执行,请稍后在 SimpleLite 查看结果)`
}
if (result.accepted) return `已执行 ${label}`
return `已执行 ${label}`
}
export interface ReflectionExecuteOptions {
/** 已在平台侧完成二次确认时带上,对应后端 X-Platform-Confirmed: 1 */
platformConfirmed?: boolean
}
async function get<T>(path: string): Promise<T> {
const { data } = await http.get<ReflectionEnvelope<T>>(`${BASE}${path}`)
if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`)
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data)
return data.data as T
}
async function post<T>(path: string, params?: Record<string, string | number | boolean>): Promise<T> {
const { data } = await http.post<ReflectionEnvelope<T>>(`${BASE}${path}`, null, { params })
if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`)
async function post<T>(
path: string,
params?: Record<string, string | number | boolean>,
headers?: Record<string, string>
): Promise<T> {
const { data } = await http.post<ReflectionEnvelope<T>>(`${BASE}${path}`, null, { params, headers })
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data)
return data.data as T
}
async function del<T>(path: string): Promise<T> {
const { data } = await http.delete<ReflectionEnvelope<T>>(`${BASE}${path}`)
if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`)
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data)
return data.data as T
}
async function patchJson<T>(path: string, body: unknown): Promise<T> {
const { data } = await http.patch<ReflectionEnvelope<T>>(`${BASE}${path}`, body)
if (!data?.success) throw new Error(data?.message ?? `reflection PATCH ${path} failed`)
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection PATCH ${path} failed`, data?.code ?? 500, data?.data)
return data.data as T
}
async function executeWithPlatformConfirm<T>(
path: string,
params?: Record<string, string | number | boolean>,
opts?: ReflectionExecuteOptions
): Promise<T> {
const headers = opts?.platformConfirmed ? { 'X-Platform-Confirmed': '1' } : undefined
try {
return await post<T>(path, params, headers)
} catch (e) {
if (e instanceof ReflectionApiError && e.code === 428 && !opts?.platformConfirmed) {
const confirmMessage =
(e.data as { confirmMessage?: string | null } | null)?.confirmMessage?.trim()
|| '此操作需要确认'
await ElMessageBox.confirm(confirmMessage, '确认', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
return await post<T>(path, params, { 'X-Platform-Confirmed': '1' })
}
throw e
}
}
export const reflectionApi = {
listKinds: () => MOCK
? Promise.resolve(mockReflectionKinds())
@@ -371,16 +438,23 @@ export const reflectionApi = {
fieldList?: ReflectionKv[]
}>(`/bundle/${kind}/${id}`),
execute: (kind: ReflectionKind, id: number, method: string, params?: Record<string, string | number | boolean>) => MOCK
execute: (
kind: ReflectionKind,
id: number,
method: string,
params?: Record<string, string | number | boolean>,
opts?: ReflectionExecuteOptions
) => MOCK
? Promise.resolve(mockReflectionExecute(
kind,
id,
method,
Object.fromEntries(Object.entries(params ?? {}).map(([k, v]) => [k, String(v)]))
))
: post<{ returnValue: string }>(
: executeWithPlatformConfirm<ReflectionExecuteResult>(
`/execute/${kind}/${id}/${encodeURIComponent(method)}`,
params
params,
opts
),
/** 车辆前往指定站点(Web「去某地」;不依赖 SimpleUI.GetPoint)。 */
@@ -34,10 +34,15 @@ export interface ToolbarRecordingState {
isRecording: boolean
mode: string
isPlaying: boolean
isAutoPlaying?: boolean
speed?: number
elapsedMs: number
durationMs?: number
frameCount: number
currentFrameIndex?: number
droppedFrames: number
currentRecordingFile: string | null
currentPlaybackFile?: string | null
recordingsDirectory: string
entries: ToolbarRecordingEntry[]
}
@@ -122,6 +127,20 @@ export const workspaceToolbarApi = {
stopPlayback: () => unwrap<ToolbarState>(http.post(`${BASE}/playback/stop`)),
playPause: () => unwrap<ToolbarState>(http.post(`${BASE}/playback/play-pause`, {})),
seekElapsed: (elapsedMs: number) =>
unwrap<ToolbarState>(http.post(`${BASE}/playback/seek`, { elapsedMs, ElapsedMs: elapsedMs })),
seekFrame: (frameIndex: number) =>
unwrap<ToolbarState>(http.post(`${BASE}/playback/seek`, { frameIndex, FrameIndex: frameIndex })),
step: (delta = 1) =>
unwrap<ToolbarState>(http.post(`${BASE}/playback/step`, { delta, Delta: delta })),
setSpeed: (speed: number) =>
unwrap<ToolbarState>(http.post(`${BASE}/playback/speed`, { speed, Speed: speed })),
toggleViewMode: () => unwrap<ToolbarState>(http.post(`${BASE}/view/toggle`)),
setCameraFollow: (carId: number | null, enabled: boolean) =>