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 { 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 } 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 alignSnap?: Partial showViewport?: Partial } 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(promise: Promise<{ data: MapEditEnvelope }>): Promise { 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(http.post( `${BASE}/pick`, { snap: opts?.snap ?? true }, { timeout: 0, signal: opts?.signal } )), cancelPick: () => unwrap<{ cancelled: boolean }>(http.post(`${BASE}/pick/cancel`)), dashboardSummary: () => unwrap(http.get(`${BASE}/dashboard/summary`)), // 资产上传:JSON body 传 base64(后端按 JSON 解析;勿手动设 multipart 头 —— body 并非 multipart)。 uploadAsset: (filename: string, dataBase64: string) => unwrap(http.post(`${BASE}/assets/upload`, { filename, data: dataBase64 })), // AI 生图 aiMapGenerate: (req: AiMapGenerateRequest) => unwrap(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(http.get(`${BASE}/view-filter`)), /** * 部分更新「过滤」菜单。只传要改的字段即可,未传字段保持原状。 * 后端写入对应 SimpleUI 静态字段 + 触发 SimpleSceneRenderer.NotifyStateChanged() * 立即重画工作区,对 SelectFilter 变化还会同步 CycleGUI 引擎层 SelectObject 模式。 */ setViewFilter: (patch: ViewFilterPatch) => unwrap(http.post(`${BASE}/view-filter`, patch)) } export const aiConfigApi = { get: () => unwrap(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(http.get(`${BASE}/maps`)), /** 读取固定地图文件夹内某张地图的 JSON 原文(MiGu.Server MapsContentController)。 */ readContent: (name: string) => http.get( `/maps/${encodeURIComponent(name)}/content` ).then((r) => r.data), sceneTaskStatus: () => unwrap(http.get(`${BASE}/maps/scene-task-status`)), /** * 保存当前场景为固定文件夹内的地图。overwrite=false 且同名已存在时后端回 409, * 这里翻译为 { ok:false, conflict:true },让调用方弹「是否替换原地图」确认框。 */ async save(name: string, overwrite = false): Promise { try { const { data } = await http.post>(`${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> 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(http.post(`${BASE}/maps/open`, { name })), /** * 设为当前使用地图并加载。后端切换前会校验场景无车辆任务;存在任务回 409, * unwrap 会抛出携带后端友好文案的 Error,调用方 try/catch 提示即可。 */ use: (name: string) => unwrap(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 { try { const { data } = await http.post>(`${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> 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 } } }