feat(platform): improve map and project save workflows

This commit is contained in:
zhaowei.huang
2026-06-08 18:15:54 +08:00
parent 991502b4b1
commit 3dd08e28e5
11 changed files with 676 additions and 87 deletions
@@ -8,6 +8,7 @@
kind-label="车辆"
title="车辆管理(Car / AbstractCar,含模拟车、插件车型 reflectionApi 实例化)"
empty-text="当前没有车辆点击右上角新建车辆从已加载的 CarType 中选一个实例化"
enable-project-save
/>
</el-tab-pane>
<el-tab-pane label="车型样式 / 报警颜色" name="style">
@@ -171,6 +171,11 @@ import { useMapEditStream } from '@/composables/useMapEditStream'
import { buildAlignOps, type AlignTarget, type AlignMode } from '@/composables/useAlignment'
import { genLinearH, genLinearV, genMatrix, genCircular } from '@/composables/useBatchGenerate'
import { mapEditApi, aiConfigApi, mapsApi } from '@/api/mapEdit'
import {
promptNewSaveName,
promptSaveMode,
resolveCurrentMapName
} from '@/utils/projectSaveFlow'
import {
reflectionApi,
normalizeViewportPayload,
@@ -1402,30 +1407,47 @@ async function onAiGenerated(r: import('@/api/mapEdit').AiMapGenerateResult) {
// 项目保存:调反射 API 触发 SimpleProject.Save (如有 MethodMember 暴露)
// ──────────────────────────────────────────────────────────────────────────
/** 生成「当前时间」默认地图名(文件名安全,不含冒号等非法字符)。 */
function defaultTimeMapName(): string {
const d = new Date()
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
}
/**
* 保存当前场景到地图管理统一目录,成功后返回地图管理页。
* - 编辑已有地图:直接覆盖保存(同名冲突时确认替换)
* - 新建地图:用当前时间自动命名
* 保存当前场景到服务器地图目录,成功后返回地图管理页。
* - 否,在原项目基础上保存:名称不变,直接覆盖原地图
* - 是,保存成新项目:默认「当前地图名_修改日期」,确认后落盘到服务器
*/
async function saveMapAndLeave() {
const name = editingMapName.value || defaultTimeMapName()
const mode = await promptSaveMode()
if (mode === 'cancel') return
let originalName = editingMapName.value.trim()
if (!originalName) {
originalName = await resolveCurrentMapName()
if (originalName) editingMapName.value = originalName
}
let name: string
let overwrite = false
if (mode === 'replace') {
if (!originalName) {
ElMessage.warning('未找到可覆盖的原地图,请选择「保存成新项目」')
return
}
name = originalName
overwrite = true
} else {
const picked = await promptNewSaveName('保存成新地图', originalName)
if (!picked) return
name = picked
}
saving.value = true
try {
let outcome = await mapsApi.save(name, false)
if (!outcome.ok && outcome.conflict) {
let outcome = await mapsApi.save(name, overwrite)
if (!outcome.ok && outcome.conflict && mode === 'new') {
try {
await ElMessageBox.confirm(
`地图「${name}已存在,是否替换原地图文件?`,
'替换确认',
{ type: 'warning', confirmButtonText: '替换', cancelButtonText: '取消' }
`服务器已存在地图「${name},是否覆盖该文件?`,
'覆盖确认',
{ type: 'warning', confirmButtonText: '覆盖', cancelButtonText: '取消' }
)
} catch {
return
@@ -1438,8 +1460,12 @@ async function saveMapAndLeave() {
return
}
editingMapName.value = outcome.data.name
ElMessage.success(`已保存地图「${outcome.data.name}`)
if (mode === 'replace') {
ElMessage.success(`覆盖保存地图「${name}`)
} else {
editingMapName.value = outcome.data.name
ElMessage.success(`新地图「${outcome.data.name}」已保存到服务器`)
}
await router.push({ path: '/admin/maps' })
} catch (err) {
ElMessage.error(`保存失败:${(err as Error).message}`)
@@ -1649,6 +1675,9 @@ async function loadFromRouteQuery() {
}
} else if (isNew) {
editingMapName.value = ''
} else {
const current = await resolveCurrentMapName()
if (current) editingMapName.value = current
}
}
@@ -27,40 +27,70 @@
</template>
</el-alert>
<el-table
v-loading="loading"
:data="maps"
class="map-table"
border
:row-class-name="rowClassName"
empty-text="固定文件夹内还没有地图点击右上角新增地图创建"
>
<el-table-column label="地图名称" min-width="220">
<template #default="{ row }">
<span class="map-name">{{ row.name }}</span>
<el-tag v-if="row.isCurrent" size="small" type="success" effect="dark" class="current-tag">
使用中
</el-tag>
<div class="mm-body">
<el-card shadow="never" class="mm-list-card">
<el-table
ref="tableRef"
v-loading="loading"
:data="maps"
class="map-table"
border
highlight-current-row
row-key="name"
:row-class-name="rowClassName"
empty-text="固定文件夹内还没有地图点击右上角新增地图创建"
@row-click="onRowClick"
>
<el-table-column label="地图名称" min-width="180">
<template #default="{ row }">
<span class="map-name">{{ row.name }}</span>
<el-tag v-if="row.isCurrent" size="small" type="success" effect="dark" class="current-tag">
使用中
</el-tag>
</template>
</el-table-column>
<el-table-column label="修改时间" prop="modified" width="170" />
<el-table-column label="操作" width="260" align="right">
<template #default="{ row }">
<el-button
size="small"
type="primary"
plain
:disabled="row.isCurrent"
:loading="busyName === row.name"
@click.stop="onUse(row)"
>
{{ row.isCurrent ? '使用中' : '使用' }}
</el-button>
<el-button size="small" @click.stop="onRename(row)">重命名</el-button>
<el-button size="small" @click.stop="onEdit(row)">编辑</el-button>
<el-button size="small" type="danger" plain @click.stop="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card shadow="never" class="mm-detail-card">
<template v-if="!viewingName">
<el-empty description="点击左侧地图行,右侧预览 JSON 配置(可按 {} 折叠)" />
</template>
</el-table-column>
<el-table-column label="修改时间" prop="modified" width="190" />
<el-table-column label="操作" width="360" align="right">
<template #default="{ row }">
<el-button
size="small"
type="primary"
:disabled="row.isCurrent"
:loading="busyName === row.name"
@click="onUse(row)"
>
{{ row.isCurrent ? '使用中' : '使用' }}
</el-button>
<el-button size="small" @click="onRename(row)">重命名</el-button>
<el-button size="small" @click="onEdit(row)">编辑</el-button>
<el-button size="small" type="danger" plain @click="onDelete(row)">删除</el-button>
<template v-else>
<div class="mm-detail-head">
<div class="mm-detail-title">
<span class="map-name">{{ viewingName }}</span>
<el-tag v-if="viewingIsCurrent" size="small" type="success" effect="dark">使用中</el-tag>
</div>
<div v-if="viewingPath" class="mm-detail-meta">
<code>{{ viewingPath }}</code>
</div>
</div>
<div v-loading="contentLoading" class="mm-json-wrap">
<JsonFoldViewer v-if="viewingJsonRaw" :raw="viewingJsonRaw" />
<el-empty v-else-if="!contentLoading" description="未能加载地图配置" />
</div>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</div>
</el-tab-pane>
@@ -76,14 +106,17 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { TableInstance } from 'element-plus'
import { mapsApi, type MapListItem } from '@/api/mapEdit'
import JsonFoldViewer from '@/components/common/JsonFoldViewer.vue'
import MapConnectionPanel from '@/components/map-manage/MapConnectionPanel.vue'
import MapMergePanel from '@/components/map-manage/MapMergePanel.vue'
const router = useRouter()
const tableRef = ref<TableInstance>()
const activeTab = ref<'maps' | 'connections' | 'merge'>('maps')
@@ -98,8 +131,47 @@ const directory = ref('')
const currentName = ref('')
const busyName = ref('')
const viewingName = ref('')
const viewingPath = ref('')
const viewingJsonRaw = ref('')
const contentLoading = ref(false)
const viewingIsCurrent = computed(
() => maps.value.find((m) => m.name === viewingName.value)?.isCurrent ?? false
)
function rowClassName({ row }: { row: MapListItem }) {
return row.isCurrent ? 'current-row' : ''
const classes = []
if (row.isCurrent) classes.push('current-row')
if (row.name === viewingName.value) classes.push('viewing-row')
return classes.join(' ')
}
async function loadMapContent(name: string) {
contentLoading.value = true
viewingJsonRaw.value = ''
viewingPath.value = ''
try {
const r = await mapsApi.readContent(name)
viewingPath.value = r.path
viewingJsonRaw.value = r.content
} catch (err) {
ElMessage.error(`加载地图配置失败:${(err as Error).message}`)
} finally {
contentLoading.value = false
}
}
async function selectMapRow(row: MapListItem) {
viewingName.value = row.name
await loadMapContent(row.name)
await nextTick()
tableRef.value?.setCurrentRow(row)
}
function onRowClick(row: MapListItem) {
if (viewingName.value === row.name && viewingJsonRaw.value) return
void selectMapRow(row)
}
async function refresh() {
@@ -110,6 +182,19 @@ async function refresh() {
directory.value = r.directory
const cur = r.maps.find((m) => m.isCurrent)
currentName.value = cur?.name ?? ''
if (viewingName.value && !r.maps.some((m) => m.name === viewingName.value)) {
viewingName.value = ''
viewingJsonRaw.value = ''
viewingPath.value = ''
tableRef.value?.setCurrentRow(undefined)
} else if (viewingName.value) {
const row = r.maps.find((m) => m.name === viewingName.value)
await loadMapContent(viewingName.value)
if (row) {
await nextTick()
tableRef.value?.setCurrentRow(row)
}
}
} catch (err) {
ElMessage.error(`加载地图列表失败:${(err as Error).message}`)
} finally {
@@ -180,6 +265,7 @@ async function onRename(row: MapListItem) {
busyName.value = row.name
await mapsApi.rename(row.name, to)
if (viewingName.value === row.name) viewingName.value = to
ElMessage.success(`已重命名为「${to}`)
await refresh()
} catch (err) {
@@ -198,6 +284,12 @@ async function onDelete(row: MapListItem) {
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
)
await mapsApi.delete(row.name)
if (viewingName.value === row.name) {
viewingName.value = ''
viewingJsonRaw.value = ''
viewingPath.value = ''
tableRef.value?.setCurrentRow(undefined)
}
ElMessage.success(`已删除「${row.name}`)
await refresh()
} catch (err) {
@@ -257,10 +349,43 @@ onMounted(refresh)
background: var(--mg-veil-2);
color: var(--mg-text-light);
}
.mm-body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(420px, 1fr) 1.2fr;
gap: 14px;
}
.mm-list-card,
.mm-detail-card {
min-height: 0;
display: flex;
flex-direction: column;
background: rgba(30, 14, 55, 0.55) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
backdrop-filter: blur(12px);
}
.mm-list-card :deep(.el-card__body),
.mm-detail-card :deep(.el-card__body) {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px;
display: flex;
flex-direction: column;
}
.map-table {
flex: 1;
min-height: 0;
}
.map-table :deep(.el-table__row) {
cursor: pointer;
}
.map-name {
font-weight: 500;
color: var(--mg-text-light);
@@ -276,4 +401,35 @@ onMounted(refresh)
.map-table :deep(.current-row):hover > td.el-table__cell {
background: rgba(var(--mg-status-success-rgb), 0.24) !important;
}
.map-table :deep(.viewing-row) > td.el-table__cell {
background: rgba(var(--mg-accent-rgb, 120, 80, 200), 0.12) !important;
}
.mm-detail-head {
flex-shrink: 0;
margin-bottom: 10px;
}
.mm-detail-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.mm-detail-meta code {
font-family: var(--mg-font-mono, monospace);
font-size: 11px;
color: var(--mg-text-muted);
word-break: break-all;
}
.mm-json-wrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>
@@ -5,6 +5,10 @@
kind-label="进程"
title="进程管理(Mission / Process"
empty-text="当前没有进程点击右上角新建进程即可基于已加载的 MissionType 实例化一个"
show-status-column
status-label="状态"
status-reflection-key="status"
enable-project-save
/>
</div>
</template>
@@ -8,6 +8,7 @@
kind-label="站点"
title="站点管理(Site / UISite,含禁用/启用、必空点等动作)"
empty-text="当前没有站点点击右上角新建站点 x/y 坐标添加"
enable-project-save
/>
</el-tab-pane>
<el-tab-pane label="路径" name="track">
@@ -17,6 +18,7 @@
kind-label="路径"
title="路径管理(Track / UITrack,含方向、冲突、投影、二分等动作)"
empty-text="当前没有路径点击右上角新建路径选起止站点添加"
enable-project-save
/>
</el-tab-pane>
<el-tab-pane label="装饰物" name="special">
@@ -26,6 +28,7 @@
kind-label="装饰物"
title="装饰物管理(UI_Image / UI_Text / UI_Model"
empty-text="当前没有装饰物新建图片/文本/模型可用底部按钮或先在地图编辑里上传资产"
enable-project-save
/>
</el-tab-pane>
</el-tabs>
@@ -4,7 +4,7 @@
平台脚本管理= SimpleLite 工作台脚本ComposerDockPanel.RenderScriptTable Web 镜像
- 列表 = CarProgram.GetPrograms()最多 300 含历史 Mission 调度运行时自动产生与回收
- ID / 名称(任务名) / 类型(CarProgram 子类) / 车辆(plans[0].usingCar) / 状态(ProgramStatus.state)
- 不渲染新建按钮CarProgram 无法手动 new要新增脚本应去任务编排页建 Mission
- 不渲染新建按钮CarProgram 无法手动 new Mission 调度运行时自动产生 SimpleLite 工作台新建 Mission 后生成
- 不渲染删除按钮CarProgram 由调度器在环形队列里维护删除一律会被后端拒
右侧详情面板沿用通用 ReflectionManagerPanel 渲染fields 多为 typed/locked只读 status 通过反射
-->
@@ -12,7 +12,7 @@
kind="script"
kind-label="脚本"
title="脚本管理(CarProgram 运行实例 · 与 SimpleLite 工作台「脚本」页对齐)"
empty-text="当前无 CarProgram 实例CarProgram Mission 运行时编译用户脚本后产生的对象要创建脚本请到任务编排页新建 Mission"
empty-text="当前无 CarProgram 实例CarProgram Mission 运行时编译用户脚本后产生的对象请在 SimpleLite 工作台新建 Mission任务后由调度运行时自动生成"
disable-create
disable-delete
show-summary-column