Files
Migu2.0/frontends/apps/simple-platform-vue/src/api/mapEdit.ts
T

416 lines
14 KiB
TypeScript
Raw Normal View History

import http from './http'
/**
* 与 SimpleLite `MapEditApiController` 对应的前端胶水。
* 路径前缀:`/sl/projection/map-edit`YARP → SimpleLite `/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 interface ViewFilterSnapshot {
selectFilter: { sites: boolean; tracks: boolean; cars: boolean; decor: boolean }
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 })),
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`)),
dashboardSummary: () =>
unwrap<DashboardSummary>(http.get(`${BASE}/dashboard/summary`)),
// 资产上传:传 base64
uploadAsset: (filename: string, dataBase64: string) =>
unwrap<AssetUploadResult>(http.post(`${BASE}/assets/upload`, { filename, data: dataBase64 }, {
headers: { 'Content-Type': 'multipart/form-data' }
})),
// 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 } : {} })),
/**
* 在 SimpleLite 桌面进程上弹 Windows 原生「打开文件」对话框,
* 让用户挑一个项目 JSON。返回 { path, cancelled }cancelled=true 时 path 为 null。
* initialDir 默认 SimpleLite 程序工作目录。
*/
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 状态
* 与 SimpleLite 真实开关一致(避免「前端勾着、后端没改」的偏差)。
*/
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))
}
// ──────────────────────────────────────────────────────────────────────────
// 地图管理(固定文件夹 + 按名称列表 / 保存 / 删除 / 使用 / 编辑打开)
// 对应 SimpleLite 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
}
/** 保存结果: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`)),
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> {
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 ?? '保存失败' }
},
/** 加载指定地图到场景以供编辑(不校验任务、不改当前使用地图)。 */
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> {
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 ?? '合并失败' }
}
}