Files
Migu2.0/frontends/apps/simple-platform-vue/src/api/mapEdit.ts
T
黄兆尉andCursor 3686abdc78 将调度内核标识从 SimpleLite 全面重命名为 Simple3。
配置段/环境变量、Launcher、健康检查 API、OpenAPI 与前后端文案同步;兼容探测旧 SimpleLite 进程名。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:46:52 +08:00

521 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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=仅 UITrackcurve=贝塞尔/弧/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 正常返回 dataconflict=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
}
}
}