feat(platform): 部署配置向导 + 地图管理,地图编辑器接入统一存取与 AI 助手
- 配置向导:登录按 deployment 画像引导平台选型(导航方式/模块/场景),未完成则路由守卫强制进入 /wizard;选型驱动菜单按需裁剪,并联动 SimpleLite 写 plugins/active-scenes.json + 透传 --scenes 选择性加载导航场景插件 - 地图管理页:服务器地图列表/使用/重命名/删除、地图合并、多地图连接管理 - 地图编辑器:项目存取改为存入地图管理统一目录(同名替换确认),支持 ?map=/?new= 进入,新增右侧可停靠 AI 助手面板 - 集成 PTL 拣选模块;新增车队分配面板(运维总览/筛选联动) - SimpleLiteBuildSync 同步运行时依赖 DLL;重新构建前端静态资源 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 多地图连接(跨楼层 / 多地图拼接)数据层。
|
||||
*
|
||||
* 业务背景:一个站点可作为「切换点站点」与另一张地图的某个切换点站点相连,
|
||||
* 配上转移代价(cm),即可在多张楼层地图之间做跨图路径规划与地图拼接。
|
||||
*
|
||||
* 持久化:后端暂无对应表,先用 localStorage 落地,保证前端功能完整、可演示;
|
||||
* 待后端补上 `/sl/projection/map-edit/connections` 系列接口后,仅需替换本文件实现,
|
||||
* 组件层(MapConnectionPanel)无需改动(接口已按异步 Promise 设计)。
|
||||
*/
|
||||
|
||||
export interface MapConnection {
|
||||
id: number
|
||||
/** 起始地图名称(与 mapsApi.list 的 name 对齐) */
|
||||
sourceMap: string
|
||||
/** 起始地图 ID(地图以名称为主键,这里按名称分配稳定数字 ID,呼应参考图的「地图ID」列) */
|
||||
sourceMapId: number
|
||||
/** 起始切换点站点 */
|
||||
sourceStation: string
|
||||
/** 目的地图名称 */
|
||||
targetMap: string
|
||||
/** 目的地图 ID */
|
||||
targetMapId: number
|
||||
/** 目的切换点站点 */
|
||||
targetStation: string
|
||||
/** 转移代价(cm) */
|
||||
cost: number
|
||||
}
|
||||
|
||||
export type MapConnectionInput = Omit<MapConnection, 'id' | 'sourceMapId' | 'targetMapId'>
|
||||
|
||||
const LS_KEY = 'mapEditor.mapConnections.v1'
|
||||
const LS_IDREG = 'mapEditor.mapIdRegistry.v1'
|
||||
|
||||
function readAll(): MapConnection[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY)
|
||||
if (!raw) return []
|
||||
const arr = JSON.parse(raw)
|
||||
return Array.isArray(arr) ? (arr as MapConnection[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll(list: MapConnection[]): void {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(list))
|
||||
}
|
||||
|
||||
function readReg(): Record<string, number> {
|
||||
try {
|
||||
return (JSON.parse(localStorage.getItem(LS_IDREG) ?? '{}') as Record<string, number>) || {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeReg(reg: Record<string, number>): void {
|
||||
localStorage.setItem(LS_IDREG, JSON.stringify(reg))
|
||||
}
|
||||
|
||||
/** 名称 → 稳定数字 ID:首次遇到某地图名时分配一个递增 ID,并持久化,保证后续一致。 */
|
||||
export function mapIdOf(name: string): number {
|
||||
if (!name) return 0
|
||||
const reg = readReg()
|
||||
if (reg[name] != null) return reg[name]
|
||||
const next = Object.values(reg).reduce((m, v) => Math.max(m, v), 0) + 1
|
||||
reg[name] = next
|
||||
writeReg(reg)
|
||||
return next
|
||||
}
|
||||
|
||||
// 模拟一点点网络延迟,让 loading 态自然,也方便日后替换为真实 http 调用。
|
||||
function later<T>(value: T): Promise<T> {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(value), 60))
|
||||
}
|
||||
|
||||
export const mapConnectionApi = {
|
||||
/** 列出全部连接(按 id 倒序,新建的在前)。 */
|
||||
list(): Promise<MapConnection[]> {
|
||||
return later(readAll().slice().sort((a, b) => b.id - a.id))
|
||||
},
|
||||
|
||||
create(input: MapConnectionInput): Promise<MapConnection> {
|
||||
const list = readAll()
|
||||
const id = list.reduce((m, c) => Math.max(m, c.id), 0) + 1
|
||||
const rec: MapConnection = {
|
||||
id,
|
||||
...input,
|
||||
sourceMapId: mapIdOf(input.sourceMap),
|
||||
targetMapId: mapIdOf(input.targetMap)
|
||||
}
|
||||
list.push(rec)
|
||||
writeAll(list)
|
||||
return later(rec)
|
||||
},
|
||||
|
||||
update(id: number, input: MapConnectionInput): Promise<MapConnection> {
|
||||
const list = readAll()
|
||||
const idx = list.findIndex((c) => c.id === id)
|
||||
if (idx < 0) return Promise.reject(new Error('连接不存在或已被删除'))
|
||||
const rec: MapConnection = {
|
||||
id,
|
||||
...input,
|
||||
sourceMapId: mapIdOf(input.sourceMap),
|
||||
targetMapId: mapIdOf(input.targetMap)
|
||||
}
|
||||
list[idx] = rec
|
||||
writeAll(list)
|
||||
return later(rec)
|
||||
},
|
||||
|
||||
remove(id: number): Promise<{ id: number; deleted: boolean }> {
|
||||
writeAll(readAll().filter((c) => c.id !== id))
|
||||
return later({ id, deleted: true })
|
||||
},
|
||||
|
||||
/** 历史用过的切换点站点名,给「切换点站点」下拉做候选(可继续手动输入新名)。 */
|
||||
stationSuggestions(): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const c of readAll()) {
|
||||
if (c.sourceStation) set.add(c.sourceStation)
|
||||
if (c.targetStation) set.add(c.targetStation)
|
||||
}
|
||||
return Array.from(set)
|
||||
}
|
||||
}
|
||||
@@ -280,3 +280,136 @@ 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 正常返回 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`)),
|
||||
|
||||
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 ?? '合并失败' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import http from './http'
|
||||
import type {
|
||||
WizardOptions,
|
||||
DeploymentProfileDto,
|
||||
SaveWizardRequest,
|
||||
EffectivePagesDto
|
||||
} from '@/types/wizard'
|
||||
|
||||
// 与 api/auth.ts 一致:VITE_USE_MOCK==='true' 时走本地假数据,便于无后端联调。
|
||||
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
|
||||
const MOCK_OPTIONS: WizardOptions = {
|
||||
navigationKinds: [
|
||||
{ id: 'magnetic', name: '磁导航', group: 'navigation', description: '磁条循迹 + 地标 / RFID 定位' },
|
||||
{ id: 'qrcode', name: '二维码导航', group: 'navigation', description: '二维码地标 + 码值地图' },
|
||||
{ id: 'laser', name: '激光导航', group: 'navigation', description: '反光板 / SLAM + 激光避障' }
|
||||
],
|
||||
modules: [
|
||||
{ id: 'wms', name: 'WMS 仓储管理', group: 'module', description: '库位 / 库存 / 出入库管理' },
|
||||
{ id: 'ptl', name: 'PTL 拣选系统', group: 'module', description: 'Pick-to-Light 亮灯拣选与播种' }
|
||||
],
|
||||
scenarios: {
|
||||
templates: [
|
||||
{ id: 'tpl-sps', name: 'SPS 物料配送', category: 'SPS' },
|
||||
{ id: 'tpl-pack', name: '电池 Pack 自动化产线', category: 'BatteryPack' },
|
||||
{ id: 'tpl-loop', name: '环线运行', category: 'Loop' },
|
||||
{ id: 'tpl-p2p', name: '点对点柔性搬运', category: 'P2P' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
let mockProfile: DeploymentProfileDto = {
|
||||
configured: false,
|
||||
platformType: 'standard',
|
||||
modules: [],
|
||||
navigationKinds: [],
|
||||
scenarios: [],
|
||||
updatedBy: 'mock',
|
||||
activeSceneIds: [],
|
||||
hiddenPages: []
|
||||
}
|
||||
|
||||
export async function getWizardOptions(): Promise<WizardOptions> {
|
||||
if (MOCK) return MOCK_OPTIONS
|
||||
const { data } = await http.get<WizardOptions>('/wizard/options')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getWizardProfile(): Promise<DeploymentProfileDto> {
|
||||
if (MOCK) return mockProfile
|
||||
const { data } = await http.get<DeploymentProfileDto>('/wizard/profile')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function saveWizardProfile(req: SaveWizardRequest): Promise<DeploymentProfileDto> {
|
||||
if (MOCK) {
|
||||
mockProfile = {
|
||||
...mockProfile,
|
||||
platformType: req.platformType ?? mockProfile.platformType,
|
||||
modules: req.modules ?? [],
|
||||
navigationKinds: req.navigationKinds ?? [],
|
||||
scenarios: req.scenarios ?? [],
|
||||
configured: true,
|
||||
activeSceneIds: (req.navigationKinds ?? []).map((k) => `scene.${k}`)
|
||||
}
|
||||
return mockProfile
|
||||
}
|
||||
const { data } = await http.put<DeploymentProfileDto>('/wizard/profile', req)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getEffectivePages(): Promise<EffectivePagesDto> {
|
||||
if (MOCK) {
|
||||
return { configured: mockProfile.configured, tailorablePages: [], enabledPages: [], hiddenPages: [] }
|
||||
}
|
||||
const { data } = await http.get<EffectivePagesDto>('/wizard/effective-pages')
|
||||
return data
|
||||
}
|
||||
@@ -130,5 +130,17 @@ export const workspaceToolbarApi = {
|
||||
CarId: carId ?? undefined,
|
||||
Enabled: enabled
|
||||
})
|
||||
),
|
||||
|
||||
/**
|
||||
* 一次性把地图相机定位(居中 + 2D 俯视)到指定车辆,不开启持续跟随。
|
||||
* 对应 SimpleLite `WorkspaceToolbarApiController.LocateCamera`(与原生「双击车辆行 = 选中+定位」一致)。
|
||||
*/
|
||||
locateCamera: (carId: number) =>
|
||||
unwrap<ToolbarState>(
|
||||
http.post(`${BASE}/camera/locate`, {
|
||||
carId,
|
||||
CarId: carId
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user