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

439 lines
15 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'
/**
* 与 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
}
export interface MapContentResult {
name: string
fileName: string
path: 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> {
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 ?? '合并失败' }
}
}