配置段/环境变量、Launcher、健康检查 API、OpenAPI 与前后端文案同步;兼容探测旧 SimpleLite 进程名。 Co-authored-by: Cursor <cursoragent@cursor.com>
521 lines
18 KiB
TypeScript
521 lines
18 KiB
TypeScript
import type { AxiosError } from 'axios'
|
||
import http from './http'
|
||
|
||
/**
|
||
* 与 Simple3 `MapEditApiController` 对应的前端胶水。
|
||
* 路径前缀:`/sl/projection/map-edit`(YARP → Simple3 `/projection/map-edit`)。
|
||
*
|
||
* 也覆盖 AI 服务配置 `/sl/projection/ai-config`(GET / POST),由 AiConfigController 提供。
|
||
*
|
||
* 所有方法都通过统一信封 `{ success, code, data, message }` 返回;调用方拿到的是 `data` 部分,
|
||
* 失败时直接抛 Error,让上层 try/catch 或 ElMessage.error 统一处理。
|
||
*/
|
||
|
||
const BASE = '/sl/projection/map-edit'
|
||
const AI_BASE = '/sl/projection/ai-config'
|
||
|
||
export interface MapEditEnvelope<T> {
|
||
success: boolean
|
||
code: number
|
||
data: T | null
|
||
message: string
|
||
}
|
||
|
||
export type MapEditKind =
|
||
| 'site'
|
||
| 'track'
|
||
| 'bezier'
|
||
| 'arc'
|
||
| 'nurbs'
|
||
| 'image'
|
||
| 'text'
|
||
| 'model'
|
||
| 'special'
|
||
|
||
export interface CreateSitePayload {
|
||
x: number
|
||
y: number
|
||
z?: number
|
||
name?: string
|
||
layer?: string
|
||
[k: string]: unknown
|
||
}
|
||
|
||
export interface CreateTrackPayload {
|
||
siteA: number
|
||
siteB: number
|
||
name?: string
|
||
layer?: string
|
||
[k: string]: unknown
|
||
}
|
||
|
||
export interface CreateImagePayload {
|
||
x: number
|
||
y: number
|
||
imagePath: string
|
||
size?: number
|
||
angle?: number
|
||
opacity?: number
|
||
name?: string
|
||
layer?: string
|
||
}
|
||
|
||
export interface CreateTextPayload {
|
||
x: number
|
||
y: number
|
||
content: string
|
||
color?: string
|
||
size?: number
|
||
name?: string
|
||
layer?: string
|
||
}
|
||
|
||
export interface CreateModelPayload {
|
||
x: number
|
||
y: number
|
||
modelPath: string
|
||
scale?: number
|
||
rotation?: number
|
||
zOffset?: number
|
||
name?: string
|
||
layer?: string
|
||
}
|
||
|
||
export interface CreateCarPayload {
|
||
x: number
|
||
y: number
|
||
/** 朝向,弧度。可省略,默认 0。 */
|
||
theta?: number
|
||
name?: string
|
||
layer?: string
|
||
}
|
||
|
||
export interface BatchOp {
|
||
action: 'create' | 'delete' | 'patch'
|
||
kind?: MapEditKind | string
|
||
id?: number
|
||
data?: Record<string, unknown>
|
||
}
|
||
|
||
export interface PickResult {
|
||
x: number
|
||
y: number
|
||
siteId: number
|
||
}
|
||
|
||
export interface DashboardSummary {
|
||
cars: number
|
||
onlineCars: number
|
||
alarmingCars: number
|
||
sites: number
|
||
tracks: number
|
||
specials: number
|
||
maps: number
|
||
missions: number
|
||
timestamp: string
|
||
}
|
||
|
||
export interface AssetUploadResult {
|
||
fileName: string
|
||
savedPath: string
|
||
/** 相对 url,前端拼上 webVRender host 即可显示。 */
|
||
url: string
|
||
}
|
||
|
||
export type SelectTrackMode = 'all' | 'straight' | 'curve'
|
||
|
||
export interface ViewFilterSnapshot {
|
||
selectFilter: {
|
||
sites: boolean
|
||
tracks: boolean
|
||
cars: boolean
|
||
decor: boolean
|
||
/** 路径子类型:all=全部;straight=仅 UITrack;curve=贝塞尔/弧/NURBS */
|
||
trackMode?: SelectTrackMode
|
||
}
|
||
alignSnap: { sites: boolean; cars: boolean; tracks: boolean }
|
||
showViewport: { sceneLabels: boolean; scenePrimitives: boolean; cars: boolean }
|
||
}
|
||
|
||
/** 部分更新;只传你要改的字段即可。后端不会覆盖未传字段。 */
|
||
export interface ViewFilterPatch {
|
||
selectFilter?: Partial<ViewFilterSnapshot['selectFilter']>
|
||
alignSnap?: Partial<ViewFilterSnapshot['alignSnap']>
|
||
showViewport?: Partial<ViewFilterSnapshot['showViewport']>
|
||
}
|
||
|
||
export interface AiMapGenerateRequest {
|
||
prompt: string
|
||
mode?: 'sites' | 'tracks' | 'sites+tracks' | 'full'
|
||
bounds?: [number, number, number, number]
|
||
referenceImageBase64?: string
|
||
layer?: string
|
||
constraints?: string
|
||
}
|
||
|
||
export interface AiMapGenerateResult {
|
||
created: Array<{ tool: string; kind: string; id: number; typeName?: string; error?: string }>
|
||
assistantText?: string
|
||
usedTools: number
|
||
}
|
||
|
||
export interface AiConfig {
|
||
endpoint: string
|
||
apiKey: string
|
||
model: string
|
||
systemPrompt: string
|
||
temperature: number
|
||
maxTokens: number
|
||
timeoutSec: number
|
||
/** 仅 GET 返回时携带:apiKey 非空 → true。 */
|
||
configured?: boolean
|
||
}
|
||
|
||
async function unwrap<T>(promise: Promise<{ data: MapEditEnvelope<T> }>): Promise<T> {
|
||
const { data } = await promise
|
||
if (!data?.success) throw new Error(data?.message ?? 'request failed')
|
||
return data.data as T
|
||
}
|
||
|
||
export const mapEditApi = {
|
||
// 对象创建/删除
|
||
createSite: (p: CreateSitePayload) =>
|
||
unwrap<{ kind: 'site'; id: number; name?: string }>(http.post(`${BASE}/objects/site`, p)),
|
||
|
||
createTrack: (kind: 'track' | 'bezier' | 'arc' | 'nurbs', p: CreateTrackPayload) =>
|
||
unwrap<{ kind: 'track'; id: number; typeName: string }>(http.post(`${BASE}/objects/${kind}`, p)),
|
||
|
||
createImage: (p: CreateImagePayload) =>
|
||
unwrap<{ kind: 'image'; id: number }>(http.post(`${BASE}/objects/image`, p)),
|
||
|
||
createText: (p: CreateTextPayload) =>
|
||
unwrap<{ kind: 'text'; id: number }>(http.post(`${BASE}/objects/text`, p)),
|
||
|
||
createModel: (p: CreateModelPayload) =>
|
||
unwrap<{ kind: 'model'; id: number }>(http.post(`${BASE}/objects/model`, p)),
|
||
|
||
/** 直接创建一辆模拟车 (DummyCar),不经过画布 pick。 */
|
||
createCar: (p: CreateCarPayload) =>
|
||
unwrap<{ kind: 'car'; id: number; typeName: string }>(http.post(`${BASE}/objects/car`, p)),
|
||
|
||
deleteObject: (kind: MapEditKind | string, id: number) =>
|
||
unwrap<{ kind: string; id: number; deleted: boolean }>(http.delete(`${BASE}/objects/${kind}/${id}`)),
|
||
|
||
batch: (ops: BatchOp[]) =>
|
||
unwrap<{ count: number; results: unknown[] }>(http.post(`${BASE}/objects/batch`, { ops })),
|
||
|
||
/**
|
||
* CAD 相对包围盒对齐(Simple3 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 })),
|
||
|
||
/** 场景已有自定义字段键(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; signal?: AbortSignal }) =>
|
||
unwrap<PickResult>(http.post(
|
||
`${BASE}/pick`,
|
||
{ snap: opts?.snap ?? true },
|
||
{ timeout: 0, signal: opts?.signal }
|
||
)),
|
||
|
||
cancelPick: () =>
|
||
unwrap<{ cancelled: boolean }>(http.post(`${BASE}/pick/cancel`)),
|
||
|
||
dashboardSummary: () =>
|
||
unwrap<DashboardSummary>(http.get(`${BASE}/dashboard/summary`)),
|
||
|
||
// 资产上传:JSON body 传 base64(后端按 JSON 解析;勿手动设 multipart 头 —— body 并非 multipart)。
|
||
uploadAsset: (filename: string, dataBase64: string) =>
|
||
unwrap<AssetUploadResult>(http.post(`${BASE}/assets/upload`, { filename, data: dataBase64 })),
|
||
|
||
// AI 生图
|
||
aiMapGenerate: (req: AiMapGenerateRequest) =>
|
||
unwrap<AiMapGenerateResult>(http.post(`${BASE}/ai/map-generate`, req)),
|
||
|
||
projectSave: (path?: string) =>
|
||
unwrap<{ path: string; savedAt: string }>(http.post(`${BASE}/project/save`, { path: path ?? '' })),
|
||
|
||
projectLoad: (path: string) =>
|
||
unwrap<{ path: string; sites: number; tracks: number; specials: number; missions: number }>(
|
||
http.post(`${BASE}/project/load`, { path })
|
||
),
|
||
|
||
projectCurrent: () =>
|
||
unwrap<{ sites: number; tracks: number; specials: number; maps: number; missions: number; timestamp: string; cwd: string }>(
|
||
http.get(`${BASE}/project/current`)
|
||
),
|
||
|
||
projectBrowse: (dir?: string) =>
|
||
unwrap<{
|
||
dir: string
|
||
parent: string | null
|
||
subdirs: Array<{ name: string; fullPath: string }>
|
||
jsons: Array<{ name: string; fullPath: string; sizeBytes: number; modified: string }>
|
||
}>(http.get(`${BASE}/project/browse`, { params: dir ? { dir } : {} })),
|
||
|
||
/**
|
||
* 在 Simple3 桌面进程上弹 Windows 原生「打开文件」对话框,
|
||
* 让用户挑一个项目 JSON。返回 { path, cancelled };cancelled=true 时 path 为 null。
|
||
* initialDir 默认 Simple3 程序工作目录。
|
||
*/
|
||
projectNativePickOpen: (initialDir?: string) =>
|
||
unwrap<{ path: string | null; cancelled: boolean }>(
|
||
http.post(`${BASE}/project/native-pick-open`, { initialDir: initialDir ?? '' })
|
||
),
|
||
|
||
/**
|
||
* 弹 Windows 原生「另存为」对话框。返回 { path, cancelled }。
|
||
* suggestedFileName 默认 project.current.json。
|
||
*/
|
||
projectNativePickSave: (initialDir?: string, suggestedFileName?: string) =>
|
||
unwrap<{ path: string | null; cancelled: boolean }>(
|
||
http.post(`${BASE}/project/native-pick-save`, {
|
||
initialDir: initialDir ?? '',
|
||
suggestedFileName: suggestedFileName ?? 'project.current.json'
|
||
})
|
||
),
|
||
|
||
sampleSitesAlongTrack: (trackId: number, opts: { spacingMm: number; includeEndpoints?: boolean; layer?: string }) =>
|
||
unwrap<{ created: number; ids: number[] }>(
|
||
http.post(`${BASE}/objects/track/${trackId}/sample-sites`, opts)
|
||
),
|
||
|
||
/**
|
||
* 读取后端「过滤」菜单当前的三组开关。编辑器载入时拉一次,让前端 reactive 状态
|
||
* 与 Simple3 真实开关一致(避免「前端勾着、后端没改」的偏差)。
|
||
*/
|
||
getViewFilter: () => unwrap<ViewFilterSnapshot>(http.get(`${BASE}/view-filter`)),
|
||
|
||
/**
|
||
* 部分更新「过滤」菜单。只传要改的字段即可,未传字段保持原状。
|
||
* 后端写入对应 SimpleUI 静态字段 + 触发 SimpleSceneRenderer.NotifyStateChanged()
|
||
* 立即重画工作区,对 SelectFilter 变化还会同步 CycleGUI 引擎层 SelectObject 模式。
|
||
*/
|
||
setViewFilter: (patch: ViewFilterPatch) =>
|
||
unwrap<ViewFilterSnapshot>(http.post(`${BASE}/view-filter`, patch))
|
||
}
|
||
|
||
export const aiConfigApi = {
|
||
get: () => unwrap<AiConfig>(http.get(`${AI_BASE}/`)),
|
||
save: (cfg: AiConfig) => unwrap<{ saved: boolean }>(http.post(`${AI_BASE}/`, cfg))
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────────
|
||
// 地图管理(固定文件夹 + 按名称列表 / 保存 / 删除 / 使用 / 编辑打开)
|
||
// 对应 Simple3 MapEditApiController 的 /map-edit/maps* 接口。
|
||
// ──────────────────────────────────────────────────────────────────────────
|
||
|
||
export interface MapListItem {
|
||
/** 地图名称(文件名去掉 .json 后缀),列表展示与各操作都以它为标识。 */
|
||
name: string
|
||
fileName: string
|
||
sizeBytes: number
|
||
modified: string
|
||
/** 是否为当前项目默认使用的地图(列表高亮)。 */
|
||
isCurrent: boolean
|
||
}
|
||
|
||
export interface MapListResult {
|
||
directory: string
|
||
currentFileName: string | null
|
||
maps: MapListItem[]
|
||
}
|
||
|
||
export interface MapLoadSummary {
|
||
name: string
|
||
fileName: string
|
||
path: string
|
||
sites: number
|
||
tracks: number
|
||
specials: number
|
||
missions: number
|
||
}
|
||
|
||
export interface SceneTaskBusyCar {
|
||
id: number
|
||
name: string
|
||
reasons: string[]
|
||
}
|
||
|
||
export interface SceneTaskStatus {
|
||
hasTask: boolean
|
||
busyCount: number
|
||
busyCars: SceneTaskBusyCar[]
|
||
}
|
||
|
||
export interface MapSaveResult {
|
||
name: string
|
||
fileName: string
|
||
path: string
|
||
savedAt: string
|
||
}
|
||
|
||
export interface MapContentResult {
|
||
name: string
|
||
fileName: string
|
||
content: string
|
||
}
|
||
|
||
/** 保存结果:success 正常返回 data;conflict=true 表示同名地图已存在,调用方应弹「替换」确认。 */
|
||
export type MapSaveOutcome =
|
||
| { ok: true; data: MapSaveResult }
|
||
| { ok: false; conflict: boolean; message: string }
|
||
|
||
export interface MapMergeResult {
|
||
name: string
|
||
fileName: string
|
||
path: string
|
||
/** 底图 = 合并时的「当前使用地图」名。 */
|
||
baseMap: string
|
||
/** 合并进来的源地图名(去重、且不含当前地图自身,按叠加顺序)。 */
|
||
sources: string[]
|
||
sourceCount: number
|
||
/** target 与当前地图同名 → 本次合并覆盖了当前地图。 */
|
||
overwroteCurrent: boolean
|
||
/** 合并后统计(用于结果提示)。 */
|
||
sites: number
|
||
tracks: number
|
||
specials: number
|
||
missions: number
|
||
savedAt: string
|
||
}
|
||
|
||
/** 合并结果:conflict=true 仅表示目标地图同名已存在,调用方应弹「替换」确认后重试。 */
|
||
export type MapMergeOutcome =
|
||
| { ok: true; data: MapMergeResult }
|
||
| { ok: false; conflict: boolean; message: string }
|
||
|
||
export const mapsApi = {
|
||
list: () => unwrap<MapListResult>(http.get(`${BASE}/maps`)),
|
||
|
||
/** 读取固定地图文件夹内某张地图的 JSON 原文(MiGu.Server MapsContentController)。 */
|
||
readContent: (name: string) =>
|
||
http.get<MapContentResult>(
|
||
`/maps/${encodeURIComponent(name)}/content`
|
||
).then((r) => r.data),
|
||
|
||
sceneTaskStatus: () => unwrap<SceneTaskStatus>(http.get(`${BASE}/maps/scene-task-status`)),
|
||
|
||
/**
|
||
* 保存当前场景为固定文件夹内的地图。overwrite=false 且同名已存在时后端回 409,
|
||
* 这里翻译为 { ok:false, conflict:true },让调用方弹「是否替换原地图」确认框。
|
||
*/
|
||
async save(name: string, overwrite = false): Promise<MapSaveOutcome> {
|
||
try {
|
||
const { data } = await http.post<MapEditEnvelope<MapSaveResult>>(`${BASE}/maps/save`, { name, overwrite })
|
||
if (data?.success) return { ok: true, data: data.data as MapSaveResult }
|
||
return { ok: false, conflict: data?.code === 409, message: data?.message ?? '保存失败' }
|
||
} catch (err) {
|
||
const ax = err as AxiosError<MapEditEnvelope<MapSaveResult>>
|
||
const body = ax.response?.data
|
||
if (ax.response?.status === 409 || body?.code === 409) {
|
||
return { ok: false, conflict: true, message: body?.message ?? '地图已存在' }
|
||
}
|
||
throw err
|
||
}
|
||
},
|
||
|
||
/** 加载指定地图到场景以供编辑(不校验任务、不改当前使用地图)。 */
|
||
open: (name: string) => unwrap<MapLoadSummary>(http.post(`${BASE}/maps/open`, { name })),
|
||
|
||
/**
|
||
* 设为当前使用地图并加载。后端切换前会校验场景无车辆任务;存在任务回 409,
|
||
* unwrap 会抛出携带后端友好文案的 Error,调用方 try/catch 提示即可。
|
||
*/
|
||
use: (name: string) => unwrap<MapLoadSummary>(http.post(`${BASE}/maps/use`, { name })),
|
||
|
||
delete: (name: string) =>
|
||
unwrap<{ name: string; fileName: string; deleted: boolean }>(
|
||
http.delete(`${BASE}/maps/${encodeURIComponent(name)}`)
|
||
),
|
||
|
||
/** 重命名 maps 目录下的地图文件(from / to 均为不含扩展名的地图名)。 */
|
||
rename: (from: string, to: string) =>
|
||
unwrap<{ from: string; to: string; fileName: string; path: string }>(
|
||
http.post(`${BASE}/maps/rename`, { from, to })
|
||
),
|
||
|
||
/**
|
||
* 把选中的地图合并进「当前使用地图」后另存为新地图(语义同桌面端「合并」= SimpleProject.ImportFile)。
|
||
* sources 为要合并进来的源地图名数组(≥1,当前地图自身会被后端忽略),以「当前使用地图」为底图依次叠加。
|
||
* target 与当前地图同名 + overwrite 即覆盖当前地图。
|
||
* 目标同名且 overwrite=false 时后端回 409 → { ok:false, conflict:true },调用方弹「替换」确认;
|
||
* 当前未设置使用地图(400)/ 场景内有车辆任务(409)时 message 不含「已存在」,conflict=false,调用方直接提示。
|
||
*/
|
||
async merge(sources: string[], target: string, overwrite = false): Promise<MapMergeOutcome> {
|
||
try {
|
||
const { data } = await http.post<MapEditEnvelope<MapMergeResult>>(`${BASE}/maps/merge`, {
|
||
sources,
|
||
target,
|
||
overwrite
|
||
})
|
||
if (data?.success) return { ok: true, data: data.data as MapMergeResult }
|
||
const conflict = data?.code === 409 && (data?.message ?? '').includes('已存在')
|
||
return { ok: false, conflict, message: data?.message ?? '合并失败' }
|
||
} catch (err) {
|
||
// 与 save() 对齐:后端以 HTTP 409 状态码返回时同样翻译为 conflict,
|
||
// 让调用方能弹「是否替换」确认而非直接报错。
|
||
const ax = err as AxiosError<MapEditEnvelope<MapMergeResult>>
|
||
const body = ax.response?.data
|
||
if (ax.response?.status === 409 || body?.code === 409) {
|
||
const message = body?.message ?? '目标地图已存在'
|
||
return { ok: false, conflict: message.includes('已存在'), message }
|
||
}
|
||
throw err
|
||
}
|
||
}
|
||
}
|