feat(platform): improve map and project save workflows
This commit is contained in:
+240
-30
@@ -59,6 +59,21 @@
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-button size="small" :loading="loading" :icon="Refresh" @click="refresh">刷新</el-button>
|
||||
<el-tooltip
|
||||
v-if="showProjectSaveButton"
|
||||
:content="projectSaveTooltip"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="savingProject"
|
||||
:icon="Document"
|
||||
@click="onSaveProject"
|
||||
>
|
||||
{{ saveButtonLabel }}
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,8 +81,10 @@
|
||||
<!-- 左:列表 -->
|
||||
<el-card shadow="never" class="ref-mgr-list-card">
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="filteredObjects"
|
||||
row-key="id"
|
||||
stripe
|
||||
border
|
||||
size="small"
|
||||
@@ -95,11 +112,12 @@
|
||||
<el-table-column label="操作" :width="rowActionsWidth">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="viewInMapEnabled"
|
||||
text
|
||||
size="small"
|
||||
@click.stop="onHighlight3D(row as ReflectionObject)"
|
||||
@click.stop="onViewInMap(row as ReflectionObject)"
|
||||
>
|
||||
3D 高亮
|
||||
查看对象
|
||||
</el-button>
|
||||
<!-- 脚本管理专用:与 SimpleLite 工作台「脚本」表格行内按钮组对齐,
|
||||
选不选中都能直接点行内「查看脚本 / 查看异常状态」弹窗。 -->
|
||||
@@ -154,13 +172,20 @@
|
||||
<span class="detail-id">#{{ current.id }}</span>
|
||||
<span class="detail-name">{{ current.name }}</span>
|
||||
</div>
|
||||
<div class="detail-actions">
|
||||
<button class="header-action-btn" @click="onHighlight3D(current)">在 3D 中高亮</button>
|
||||
<div v-if="viewInMapEnabled" class="detail-actions">
|
||||
<button class="header-action-btn" @click="onViewInMap(current)">查看对象</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 强类型字段([FieldMember]) -->
|
||||
<div class="field-section">
|
||||
<el-tabs v-model="detailTab" class="detail-tabs">
|
||||
<el-tab-pane label="属性" name="properties" />
|
||||
<el-tab-pane label="状态" name="status" />
|
||||
<el-tab-pane label="动作" name="action" />
|
||||
</el-tabs>
|
||||
|
||||
<div class="detail-tab-body">
|
||||
<template v-if="detailTab === 'properties'">
|
||||
<div class="field-section field-section--first">
|
||||
<div class="field-section-title">强类型字段 ({{ typedFields.length }})</div>
|
||||
<el-descriptions
|
||||
v-if="typedFields.length > 0"
|
||||
@@ -250,11 +275,12 @@
|
||||
</el-descriptions>
|
||||
<div v-else class="field-empty">尚无动态字段;点击右上角「+ 添加字段」</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 运行状态(只读) -->
|
||||
<div v-if="status.length > 0" class="field-section">
|
||||
<template v-else-if="detailTab === 'status'">
|
||||
<div class="field-section field-section--first">
|
||||
<div class="field-section-title">运行状态 (只读, {{ status.length }})</div>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions v-if="status.length > 0" :column="1" border size="small">
|
||||
<el-descriptions-item v-for="s in status" :key="s.key" :label="statusItemLabel(s)">
|
||||
<span
|
||||
:class="['status-value', { 'status-value--array': isArrayLikeStatus(s) }]"
|
||||
@@ -262,10 +288,12 @@
|
||||
>{{ formatStatusDisplay(s) }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div v-else class="field-empty">暂无运行状态信息</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 动作 / 方法(紫色渐变按钮,配色与地图编辑栏一致) -->
|
||||
<div class="field-section">
|
||||
<template v-else>
|
||||
<div class="field-section field-section--first">
|
||||
<div class="field-section-title">动作 / 方法 ({{ actionCount }})</div>
|
||||
<div v-if="loadingMethods" class="field-empty">加载方法中…</div>
|
||||
<template v-else>
|
||||
@@ -316,6 +344,8 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
@@ -436,9 +466,10 @@
|
||||
* SSE 订阅:object-created/deleted/patched/batch-changed 都会自动 refresh。
|
||||
*/
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Plus, Refresh, CaretBottom, Connection } from '@element-plus/icons-vue'
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, type TableInstance } from 'element-plus'
|
||||
import { Search, Plus, Refresh, CaretBottom, Connection, Document } from '@element-plus/icons-vue'
|
||||
import {
|
||||
reflectionApi,
|
||||
type ReflectionKind,
|
||||
@@ -448,8 +479,19 @@ import {
|
||||
type ReflectionKv
|
||||
} from '@/api/reflection'
|
||||
import { useMapEditStream } from '@/composables/useMapEditStream'
|
||||
import type { DetailTab } from '@/types/workbench'
|
||||
import {
|
||||
buildMapFocusQuery,
|
||||
isMapViewableKind,
|
||||
MAP_MONITOR_PATH,
|
||||
toMapFocusKind
|
||||
} from '@/utils/mapObjectFocus'
|
||||
import { isProjectPersistableKind } from '@/utils/projectPersistence'
|
||||
import { executeProjectSave, promptSaveMode } from '@/utils/projectSaveFlow'
|
||||
import PluginListPanel from './PluginListPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
kind: ReflectionKind
|
||||
/** 单数标签,例如「进程」「脚本」。用于按钮文案 / 搜索框占位。 */
|
||||
@@ -478,8 +520,18 @@ const props = defineProps<{
|
||||
showStatusColumn?: boolean
|
||||
/** 「状态」列表头文案。默认「状态」。 */
|
||||
statusLabel?: string
|
||||
/**
|
||||
* 列表状态列取自运行状态反射中的 key(如进程 Mission 的 status)。
|
||||
*/
|
||||
statusReflectionKey?: string
|
||||
/** 脚本管理专用:在动作区追加 SimpleLite 工作台同款「查看脚本 / 查看异常状态」。 */
|
||||
showScriptActions?: boolean
|
||||
/** 增删改后是否自动写回项目 JSON。默认车辆/场景/进程启用。 */
|
||||
autoSaveProject?: boolean
|
||||
/** 是否展示「保存」按钮;默认车辆/场景/进程显示。 */
|
||||
enableProjectSave?: boolean
|
||||
/** 保存按钮文案,默认「保存」。 */
|
||||
saveButtonLabel?: string
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -489,6 +541,8 @@ const deleting = ref<number | null>(null)
|
||||
const deletingField = ref<string | null>(null)
|
||||
const savingField = ref<string | null>(null)
|
||||
const reloadingPlugins = ref(false)
|
||||
const savingProject = ref(false)
|
||||
const lastProjectSavePath = ref<string | null>(null)
|
||||
const creating = ref(false)
|
||||
const search = ref('')
|
||||
const scriptActionLoading = ref<'source' | 'status' | null>(null)
|
||||
@@ -498,11 +552,14 @@ const scriptDialogTitle = ref('')
|
||||
const scriptDialogContent = ref('')
|
||||
|
||||
const pluginPanelRef = ref<InstanceType<typeof PluginListPanel> | null>(null)
|
||||
const tableRef = ref<TableInstance | null>(null)
|
||||
const detailLoadingForId = ref<number | null>(null)
|
||||
|
||||
const objects = ref<ReflectionObject[]>([])
|
||||
const creatableTypes = ref<ReflectionCreatableType[]>([])
|
||||
|
||||
const current = ref<ReflectionObject | null>(null)
|
||||
const detailTab = ref<DetailTab>('properties')
|
||||
interface MutableKv { key: string; value: string; locked?: boolean; source?: 'typed' | 'dynamic'; typeName?: string }
|
||||
const allFields = ref<MutableKv[]>([])
|
||||
const status = ref<ReflectionKv[]>([])
|
||||
@@ -512,17 +569,27 @@ const typedFields = computed(() => allFields.value.filter((f) => f.source !== 'd
|
||||
const dynamicFields = computed(() => allFields.value.filter((f) => f.source === 'dynamic'))
|
||||
const scriptActionsVisible = computed(() => !!props.showScriptActions && props.kind === 'script' && !!current.value)
|
||||
const actionCount = computed(() => methods.value.length + (scriptActionsVisible.value ? 2 : 0))
|
||||
const viewInMapEnabled = computed(() => isMapViewableKind(props.kind))
|
||||
const showProjectSaveButton = computed(
|
||||
() => props.enableProjectSave ?? isProjectPersistableKind(props.kind)
|
||||
)
|
||||
const autoSaveProjectEnabled = computed(
|
||||
() => props.autoSaveProject ?? isProjectPersistableKind(props.kind)
|
||||
)
|
||||
const saveButtonLabel = computed(() => props.saveButtonLabel ?? '保存')
|
||||
const projectSaveTooltip = computed(() => {
|
||||
if (lastProjectSavePath.value) {
|
||||
return `将当前内存项目写回 JSON;上次保存:${lastProjectSavePath.value}`
|
||||
}
|
||||
return '将场景/车辆/进程等修改写回项目 JSON,下次启动可自动加载'
|
||||
})
|
||||
|
||||
// 「操作」列宽度:3D 高亮(70) + 可选行内查看脚本(80) + 异常状态(80) + 可选删除(60) + 间距。
|
||||
// 三种典型组合:
|
||||
// - 仅 3D 高亮:110
|
||||
// - 3D 高亮 + 删除:170
|
||||
// - 3D 高亮 + 查看脚本 + 异常状态(脚本管理 disableDelete):240
|
||||
const rowActionsWidth = computed(() => {
|
||||
let w = 110
|
||||
let w = 0
|
||||
if (viewInMapEnabled.value) w += 90
|
||||
if (props.showScriptActions && props.kind === 'script') w += 130
|
||||
if (!props.disableDelete) w += 60
|
||||
return w
|
||||
return Math.max(w, 72)
|
||||
})
|
||||
|
||||
const filteredObjects = computed(() => {
|
||||
@@ -540,16 +607,68 @@ const filteredObjects = computed(() => {
|
||||
const PICKER_KINDS = new Set<ReflectionKind>(['process', 'mission', 'car', 'vehicle'])
|
||||
const needsTypeNamePicker = computed(() => PICKER_KINDS.has(props.kind))
|
||||
|
||||
function pickReflectionStatusValue(rows: ReflectionKv[], key: string): string | null {
|
||||
const hit = rows.find((r) => r.key === key)
|
||||
const v = hit?.value?.trim()
|
||||
return v ? v : null
|
||||
}
|
||||
|
||||
async function enrichObjectsWithReflectionStatus(list: ReflectionObject[]): Promise<ReflectionObject[]> {
|
||||
const key = props.statusReflectionKey
|
||||
if (!props.showStatusColumn || !key || list.length === 0) return list
|
||||
// 限并发:对象较多时(如进程/库位上百条)避免一次性发起 N 个 status 请求压垮浏览器连接池与后端,
|
||||
// 用固定大小的 worker 池逐个领取任务;result[i] 按下标回填,保持与入参一致的顺序。
|
||||
const CONCURRENCY = 6
|
||||
const result = list.slice()
|
||||
let cursor = 0
|
||||
const worker = async () => {
|
||||
while (cursor < list.length) {
|
||||
const i = cursor++
|
||||
const o = list[i]
|
||||
try {
|
||||
const realKind = (o.subKind as ReflectionKind | undefined) ?? props.kind
|
||||
const rows = await reflectionApi.getStatus(realKind, o.id)
|
||||
const v = pickReflectionStatusValue(rows, key)
|
||||
if (v) result[i] = { ...o, status: v }
|
||||
} catch { /* keep list row */ }
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, list.length) }, worker))
|
||||
return result
|
||||
}
|
||||
|
||||
function syncListStatusFromBundle(rowId: number, statusRows: ReflectionKv[]) {
|
||||
const key = props.statusReflectionKey
|
||||
if (!props.showStatusColumn || !key) return
|
||||
const v = pickReflectionStatusValue(statusRows, key)
|
||||
if (!v) return
|
||||
const idx = objects.value.findIndex((o) => o.id === rowId)
|
||||
if (idx < 0) return
|
||||
if (objects.value[idx].status !== v) objects.value[idx].status = v
|
||||
}
|
||||
|
||||
function restoreTableCurrentRow(rowId: number | null | undefined) {
|
||||
if (rowId == null) return
|
||||
const row = objects.value.find((o) => o.id === rowId)
|
||||
if (!row) return
|
||||
void nextTick(() => tableRef.value?.setCurrentRow(row))
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
const preservedId = current.value?.id ?? null
|
||||
try {
|
||||
const [list, types] = await Promise.all([
|
||||
reflectionApi.listObjects(props.kind),
|
||||
reflectionApi.listCreatableTypes(props.kind).catch(() => [])
|
||||
])
|
||||
objects.value = list
|
||||
objects.value = await enrichObjectsWithReflectionStatus(list)
|
||||
creatableTypes.value = types
|
||||
if (current.value && !objects.value.find((o) => o.id === current.value!.id)) {
|
||||
const preserved = preservedId != null ? objects.value.find((o) => o.id === preservedId) : undefined
|
||||
if (preserved) {
|
||||
current.value = preserved
|
||||
restoreTableCurrentRow(preserved.id)
|
||||
} else if (preservedId != null) {
|
||||
current.value = null
|
||||
allFields.value = []
|
||||
methods.value = []
|
||||
@@ -563,11 +682,22 @@ async function refresh() {
|
||||
}
|
||||
|
||||
async function onSelect(row: ReflectionObject | null) {
|
||||
if (!row) {
|
||||
if (detailLoadingForId.value != null) return
|
||||
current.value = null
|
||||
allFields.value = []
|
||||
methods.value = []
|
||||
status.value = []
|
||||
return
|
||||
}
|
||||
// 仅在切换到「不同对象」时重置回属性页;同一对象的 SSE patch / refresh 重选要保留用户当前所在标签(状态/动作)。
|
||||
const isSameRow = current.value?.id === row.id
|
||||
current.value = row
|
||||
if (!isSameRow) detailTab.value = 'properties'
|
||||
allFields.value = []
|
||||
methods.value = []
|
||||
status.value = []
|
||||
if (!row) return
|
||||
detailLoadingForId.value = row.id
|
||||
loadingMethods.value = true
|
||||
try {
|
||||
// 注意:scene 合成 kind 下的行带 subKind,bundle/execute/setField 必须走子 kind
|
||||
@@ -585,10 +715,13 @@ async function onSelect(row: ReflectionObject | null) {
|
||||
}
|
||||
methods.value = b.methods ?? []
|
||||
status.value = b.status ?? []
|
||||
syncListStatusFromBundle(row.id, status.value)
|
||||
restoreTableCurrentRow(row.id)
|
||||
} catch (err) {
|
||||
ElMessage.error(`加载详情失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
loadingMethods.value = false
|
||||
detailLoadingForId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,6 +729,61 @@ function resolveKindForRow(row: ReflectionObject | null): ReflectionKind {
|
||||
return ((row?.subKind as ReflectionKind | undefined) ?? props.kind)
|
||||
}
|
||||
|
||||
async function saveProjectToDisk(opts?: { quiet?: boolean }): Promise<boolean> {
|
||||
if (!showProjectSaveButton.value && !autoSaveProjectEnabled.value) return true
|
||||
if (savingProject.value) return false
|
||||
savingProject.value = true
|
||||
try {
|
||||
const r = await reflectionApi.saveProject()
|
||||
lastProjectSavePath.value = r.path
|
||||
if (!opts?.quiet) {
|
||||
ElMessage.success({ message: `项目已保存:${r.path}`, grouping: true })
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
ElMessage.warning({
|
||||
message: `保存项目到本地失败:${(err as Error).message}`,
|
||||
grouping: true
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
savingProject.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function persistProjectAfterMutation() {
|
||||
if (!autoSaveProjectEnabled.value) return
|
||||
const ok = await saveProjectToDisk({ quiet: true })
|
||||
if (!ok) {
|
||||
ElMessage.warning({
|
||||
message: `变更已生效于内存,但未能写入项目文件,请稍后点击「${saveButtonLabel.value}」重试`,
|
||||
grouping: true,
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveProject() {
|
||||
const mode = await promptSaveMode()
|
||||
if (mode === 'cancel') return
|
||||
if (savingProject.value) return
|
||||
savingProject.value = true
|
||||
try {
|
||||
const path = await executeProjectSave(mode)
|
||||
if (path) {
|
||||
lastProjectSavePath.value = path
|
||||
ElMessage.success({ message: `项目已保存:${path}`, grouping: true })
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.warning({
|
||||
message: `保存项目到本地失败:${(err as Error).message}`,
|
||||
grouping: true
|
||||
})
|
||||
} finally {
|
||||
savingProject.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onReloadPlugins() {
|
||||
reloadingPlugins.value = true
|
||||
try {
|
||||
@@ -726,6 +914,7 @@ async function onConfirmCreate() {
|
||||
await refresh()
|
||||
const row = objects.value.find((o) => o.id === created.id)
|
||||
if (row) await onSelect(row)
|
||||
await persistProjectAfterMutation()
|
||||
} catch (err) {
|
||||
ElMessage.error(`创建失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
@@ -751,6 +940,7 @@ async function onCreateByTypeName(typeName: string) {
|
||||
await refresh()
|
||||
const row = objects.value.find((o) => o.id === created.id)
|
||||
if (row) await onSelect(row)
|
||||
await persistProjectAfterMutation()
|
||||
} catch (err) {
|
||||
ElMessage.error(`创建失败:${(err as Error).message}`)
|
||||
}
|
||||
@@ -773,6 +963,7 @@ async function onDelete(row: ReflectionObject) {
|
||||
ElMessage.success(`已删除 ${row.typeName} #${row.id}`)
|
||||
if (current.value?.id === row.id) current.value = null
|
||||
await refresh()
|
||||
await persistProjectAfterMutation()
|
||||
} catch (err) {
|
||||
ElMessage.error(`删除失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
@@ -787,6 +978,7 @@ async function onSetField(key: string, value: string) {
|
||||
const realKind = resolveKindForRow(current.value)
|
||||
await reflectionApi.setField(realKind, current.value.id, key, value)
|
||||
ElMessage.success(`已更新 ${key}`)
|
||||
await persistProjectAfterMutation()
|
||||
} catch (err) {
|
||||
ElMessage.error(`更新 ${key} 失败:${(err as Error).message}`)
|
||||
await onSelect(current.value)
|
||||
@@ -807,6 +999,7 @@ async function onDeleteField(key: string) {
|
||||
await reflectionApi.deleteField(realKind, current.value.id, key)
|
||||
ElMessage.success(`已删除字段 ${key}`)
|
||||
await onSelect(current.value)
|
||||
await persistProjectAfterMutation()
|
||||
} catch (err) {
|
||||
ElMessage.error(`删除字段失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
@@ -839,14 +1032,18 @@ async function onAddField() {
|
||||
await onSelect(current.value)
|
||||
}
|
||||
|
||||
async function onHighlight3D(row: ReflectionObject | null) {
|
||||
if (!row) return
|
||||
async function onViewInMap(row: ReflectionObject | null) {
|
||||
if (!row || !viewInMapEnabled.value) return
|
||||
const realKind = resolveKindForRow(row)
|
||||
const focusKind = toMapFocusKind(realKind)
|
||||
if (!focusKind) return
|
||||
try {
|
||||
const realKind = resolveKindForRow(row)
|
||||
await reflectionApi.setSelection(realKind, row.id)
|
||||
ElMessage.success(`已在 3D 视口高亮 ${row.typeName ?? realKind} #${row.id}`)
|
||||
await router.push({
|
||||
path: MAP_MONITOR_PATH,
|
||||
query: buildMapFocusQuery(focusKind, row.id)
|
||||
})
|
||||
} catch (err) {
|
||||
ElMessage.error(`高亮失败:${(err as Error).message}`)
|
||||
ElMessage.error(`跳转地图失败:${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1096,9 +1293,22 @@ onMounted(refresh)
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
|
||||
.detail-tabs :deep(.el-tabs__item) { color: var(--mg-text-muted) !important; }
|
||||
.detail-tabs :deep(.el-tabs__item.is-active) { color: var(--mg-text-light) !important; }
|
||||
.detail-tab-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.field-section {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.field-section--first {
|
||||
margin-top: 0;
|
||||
}
|
||||
.field-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user