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:
zhaowei.huang
2026-06-03 09:37:07 +08:00
co-authored by Cursor
parent e2269e430d
commit 7382e85598
109 changed files with 3481 additions and 398 deletions
@@ -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 正常返回 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 ?? '合并失败' }
}
}
@@ -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
})
)
}