feat(platform): improve map and project save workflows
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AxiosError } from 'axios'
|
||||||
import http from './http'
|
import http from './http'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -331,6 +332,13 @@ export interface MapSaveResult {
|
|||||||
savedAt: string
|
savedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MapContentResult {
|
||||||
|
name: string
|
||||||
|
fileName: string
|
||||||
|
path: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
/** 保存结果:success 正常返回 data;conflict=true 表示同名地图已存在,调用方应弹「替换」确认。 */
|
/** 保存结果:success 正常返回 data;conflict=true 表示同名地图已存在,调用方应弹「替换」确认。 */
|
||||||
export type MapSaveOutcome =
|
export type MapSaveOutcome =
|
||||||
| { ok: true; data: MapSaveResult }
|
| { ok: true; data: MapSaveResult }
|
||||||
@@ -363,6 +371,12 @@ export type MapMergeOutcome =
|
|||||||
export const mapsApi = {
|
export const mapsApi = {
|
||||||
list: () => unwrap<MapListResult>(http.get(`${BASE}/maps`)),
|
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`)),
|
sceneTaskStatus: () => unwrap<SceneTaskStatus>(http.get(`${BASE}/maps/scene-task-status`)),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -370,9 +384,18 @@ export const mapsApi = {
|
|||||||
* 这里翻译为 { ok:false, conflict:true },让调用方弹「是否替换原地图」确认框。
|
* 这里翻译为 { ok:false, conflict:true },让调用方弹「是否替换原地图」确认框。
|
||||||
*/
|
*/
|
||||||
async save(name: string, overwrite = false): Promise<MapSaveOutcome> {
|
async save(name: string, overwrite = false): Promise<MapSaveOutcome> {
|
||||||
const { data } = await http.post<MapEditEnvelope<MapSaveResult>>(`${BASE}/maps/save`, { name, overwrite })
|
try {
|
||||||
if (data?.success) return { ok: true, data: data.data as MapSaveResult }
|
const { data } = await http.post<MapEditEnvelope<MapSaveResult>>(`${BASE}/maps/save`, { name, overwrite })
|
||||||
return { ok: false, conflict: data?.code === 409, message: data?.message ?? '保存失败' }
|
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
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 加载指定地图到场景以供编辑(不校验任务、不改当前使用地图)。 */
|
/** 加载指定地图到场景以供编辑(不校验任务、不改当前使用地图)。 */
|
||||||
|
|||||||
+240
-30
@@ -59,6 +59,21 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<el-button size="small" :loading="loading" :icon="Refresh" @click="refresh">刷新</el-button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -66,8 +81,10 @@
|
|||||||
<!-- 左:列表 -->
|
<!-- 左:列表 -->
|
||||||
<el-card shadow="never" class="ref-mgr-list-card">
|
<el-card shadow="never" class="ref-mgr-list-card">
|
||||||
<el-table
|
<el-table
|
||||||
|
ref="tableRef"
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
:data="filteredObjects"
|
:data="filteredObjects"
|
||||||
|
row-key="id"
|
||||||
stripe
|
stripe
|
||||||
border
|
border
|
||||||
size="small"
|
size="small"
|
||||||
@@ -95,11 +112,12 @@
|
|||||||
<el-table-column label="操作" :width="rowActionsWidth">
|
<el-table-column label="操作" :width="rowActionsWidth">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="viewInMapEnabled"
|
||||||
text
|
text
|
||||||
size="small"
|
size="small"
|
||||||
@click.stop="onHighlight3D(row as ReflectionObject)"
|
@click.stop="onViewInMap(row as ReflectionObject)"
|
||||||
>
|
>
|
||||||
3D 高亮
|
查看对象
|
||||||
</el-button>
|
</el-button>
|
||||||
<!-- 脚本管理专用:与 SimpleLite 工作台「脚本」表格行内按钮组对齐,
|
<!-- 脚本管理专用:与 SimpleLite 工作台「脚本」表格行内按钮组对齐,
|
||||||
选不选中都能直接点行内「查看脚本 / 查看异常状态」弹窗。 -->
|
选不选中都能直接点行内「查看脚本 / 查看异常状态」弹窗。 -->
|
||||||
@@ -154,13 +172,20 @@
|
|||||||
<span class="detail-id">#{{ current.id }}</span>
|
<span class="detail-id">#{{ current.id }}</span>
|
||||||
<span class="detail-name">{{ current.name }}</span>
|
<span class="detail-name">{{ current.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-actions">
|
<div v-if="viewInMapEnabled" class="detail-actions">
|
||||||
<button class="header-action-btn" @click="onHighlight3D(current)">在 3D 中高亮</button>
|
<button class="header-action-btn" @click="onViewInMap(current)">查看对象</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 强类型字段([FieldMember]) -->
|
<el-tabs v-model="detailTab" class="detail-tabs">
|
||||||
<div class="field-section">
|
<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>
|
<div class="field-section-title">强类型字段 ({{ typedFields.length }})</div>
|
||||||
<el-descriptions
|
<el-descriptions
|
||||||
v-if="typedFields.length > 0"
|
v-if="typedFields.length > 0"
|
||||||
@@ -250,11 +275,12 @@
|
|||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
<div v-else class="field-empty">尚无动态字段;点击右上角「+ 添加字段」</div>
|
<div v-else class="field-empty">尚无动态字段;点击右上角「+ 添加字段」</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 运行状态(只读) -->
|
<template v-else-if="detailTab === 'status'">
|
||||||
<div v-if="status.length > 0" class="field-section">
|
<div class="field-section field-section--first">
|
||||||
<div class="field-section-title">运行状态 (只读, {{ status.length }})</div>
|
<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)">
|
<el-descriptions-item v-for="s in status" :key="s.key" :label="statusItemLabel(s)">
|
||||||
<span
|
<span
|
||||||
:class="['status-value', { 'status-value--array': isArrayLikeStatus(s) }]"
|
:class="['status-value', { 'status-value--array': isArrayLikeStatus(s) }]"
|
||||||
@@ -262,10 +288,12 @@
|
|||||||
>{{ formatStatusDisplay(s) }}</span>
|
>{{ formatStatusDisplay(s) }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
<div v-else class="field-empty">暂无运行状态信息</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 动作 / 方法(紫色渐变按钮,配色与地图编辑栏一致) -->
|
<template v-else>
|
||||||
<div class="field-section">
|
<div class="field-section field-section--first">
|
||||||
<div class="field-section-title">动作 / 方法 ({{ actionCount }})</div>
|
<div class="field-section-title">动作 / 方法 ({{ actionCount }})</div>
|
||||||
<div v-if="loadingMethods" class="field-empty">加载方法中…</div>
|
<div v-if="loadingMethods" class="field-empty">加载方法中…</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -316,6 +344,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -436,9 +466,10 @@
|
|||||||
* SSE 订阅:object-created/deleted/patched/batch-changed 都会自动 refresh。
|
* SSE 订阅:object-created/deleted/patched/batch-changed 都会自动 refresh。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { useRouter } from 'vue-router'
|
||||||
import { Search, Plus, Refresh, CaretBottom, Connection } from '@element-plus/icons-vue'
|
import { ElMessage, ElMessageBox, type TableInstance } from 'element-plus'
|
||||||
|
import { Search, Plus, Refresh, CaretBottom, Connection, Document } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
reflectionApi,
|
reflectionApi,
|
||||||
type ReflectionKind,
|
type ReflectionKind,
|
||||||
@@ -448,8 +479,19 @@ import {
|
|||||||
type ReflectionKv
|
type ReflectionKv
|
||||||
} from '@/api/reflection'
|
} from '@/api/reflection'
|
||||||
import { useMapEditStream } from '@/composables/useMapEditStream'
|
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'
|
import PluginListPanel from './PluginListPanel.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
kind: ReflectionKind
|
kind: ReflectionKind
|
||||||
/** 单数标签,例如「进程」「脚本」。用于按钮文案 / 搜索框占位。 */
|
/** 单数标签,例如「进程」「脚本」。用于按钮文案 / 搜索框占位。 */
|
||||||
@@ -478,8 +520,18 @@ const props = defineProps<{
|
|||||||
showStatusColumn?: boolean
|
showStatusColumn?: boolean
|
||||||
/** 「状态」列表头文案。默认「状态」。 */
|
/** 「状态」列表头文案。默认「状态」。 */
|
||||||
statusLabel?: string
|
statusLabel?: string
|
||||||
|
/**
|
||||||
|
* 列表状态列取自运行状态反射中的 key(如进程 Mission 的 status)。
|
||||||
|
*/
|
||||||
|
statusReflectionKey?: string
|
||||||
/** 脚本管理专用:在动作区追加 SimpleLite 工作台同款「查看脚本 / 查看异常状态」。 */
|
/** 脚本管理专用:在动作区追加 SimpleLite 工作台同款「查看脚本 / 查看异常状态」。 */
|
||||||
showScriptActions?: boolean
|
showScriptActions?: boolean
|
||||||
|
/** 增删改后是否自动写回项目 JSON。默认车辆/场景/进程启用。 */
|
||||||
|
autoSaveProject?: boolean
|
||||||
|
/** 是否展示「保存」按钮;默认车辆/场景/进程显示。 */
|
||||||
|
enableProjectSave?: boolean
|
||||||
|
/** 保存按钮文案,默认「保存」。 */
|
||||||
|
saveButtonLabel?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -489,6 +541,8 @@ const deleting = ref<number | null>(null)
|
|||||||
const deletingField = ref<string | null>(null)
|
const deletingField = ref<string | null>(null)
|
||||||
const savingField = ref<string | null>(null)
|
const savingField = ref<string | null>(null)
|
||||||
const reloadingPlugins = ref(false)
|
const reloadingPlugins = ref(false)
|
||||||
|
const savingProject = ref(false)
|
||||||
|
const lastProjectSavePath = ref<string | null>(null)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const scriptActionLoading = ref<'source' | 'status' | null>(null)
|
const scriptActionLoading = ref<'source' | 'status' | null>(null)
|
||||||
@@ -498,11 +552,14 @@ const scriptDialogTitle = ref('')
|
|||||||
const scriptDialogContent = ref('')
|
const scriptDialogContent = ref('')
|
||||||
|
|
||||||
const pluginPanelRef = ref<InstanceType<typeof PluginListPanel> | null>(null)
|
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 objects = ref<ReflectionObject[]>([])
|
||||||
const creatableTypes = ref<ReflectionCreatableType[]>([])
|
const creatableTypes = ref<ReflectionCreatableType[]>([])
|
||||||
|
|
||||||
const current = ref<ReflectionObject | null>(null)
|
const current = ref<ReflectionObject | null>(null)
|
||||||
|
const detailTab = ref<DetailTab>('properties')
|
||||||
interface MutableKv { key: string; value: string; locked?: boolean; source?: 'typed' | 'dynamic'; typeName?: string }
|
interface MutableKv { key: string; value: string; locked?: boolean; source?: 'typed' | 'dynamic'; typeName?: string }
|
||||||
const allFields = ref<MutableKv[]>([])
|
const allFields = ref<MutableKv[]>([])
|
||||||
const status = ref<ReflectionKv[]>([])
|
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 dynamicFields = computed(() => allFields.value.filter((f) => f.source === 'dynamic'))
|
||||||
const scriptActionsVisible = computed(() => !!props.showScriptActions && props.kind === 'script' && !!current.value)
|
const scriptActionsVisible = computed(() => !!props.showScriptActions && props.kind === 'script' && !!current.value)
|
||||||
const actionCount = computed(() => methods.value.length + (scriptActionsVisible.value ? 2 : 0))
|
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(() => {
|
const rowActionsWidth = computed(() => {
|
||||||
let w = 110
|
let w = 0
|
||||||
|
if (viewInMapEnabled.value) w += 90
|
||||||
if (props.showScriptActions && props.kind === 'script') w += 130
|
if (props.showScriptActions && props.kind === 'script') w += 130
|
||||||
if (!props.disableDelete) w += 60
|
if (!props.disableDelete) w += 60
|
||||||
return w
|
return Math.max(w, 72)
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredObjects = computed(() => {
|
const filteredObjects = computed(() => {
|
||||||
@@ -540,16 +607,68 @@ const filteredObjects = computed(() => {
|
|||||||
const PICKER_KINDS = new Set<ReflectionKind>(['process', 'mission', 'car', 'vehicle'])
|
const PICKER_KINDS = new Set<ReflectionKind>(['process', 'mission', 'car', 'vehicle'])
|
||||||
const needsTypeNamePicker = computed(() => PICKER_KINDS.has(props.kind))
|
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() {
|
async function refresh() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
const preservedId = current.value?.id ?? null
|
||||||
try {
|
try {
|
||||||
const [list, types] = await Promise.all([
|
const [list, types] = await Promise.all([
|
||||||
reflectionApi.listObjects(props.kind),
|
reflectionApi.listObjects(props.kind),
|
||||||
reflectionApi.listCreatableTypes(props.kind).catch(() => [])
|
reflectionApi.listCreatableTypes(props.kind).catch(() => [])
|
||||||
])
|
])
|
||||||
objects.value = list
|
objects.value = await enrichObjectsWithReflectionStatus(list)
|
||||||
creatableTypes.value = types
|
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
|
current.value = null
|
||||||
allFields.value = []
|
allFields.value = []
|
||||||
methods.value = []
|
methods.value = []
|
||||||
@@ -563,11 +682,22 @@ async function refresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onSelect(row: ReflectionObject | null) {
|
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
|
current.value = row
|
||||||
|
if (!isSameRow) detailTab.value = 'properties'
|
||||||
allFields.value = []
|
allFields.value = []
|
||||||
methods.value = []
|
methods.value = []
|
||||||
status.value = []
|
status.value = []
|
||||||
if (!row) return
|
detailLoadingForId.value = row.id
|
||||||
loadingMethods.value = true
|
loadingMethods.value = true
|
||||||
try {
|
try {
|
||||||
// 注意:scene 合成 kind 下的行带 subKind,bundle/execute/setField 必须走子 kind
|
// 注意:scene 合成 kind 下的行带 subKind,bundle/execute/setField 必须走子 kind
|
||||||
@@ -585,10 +715,13 @@ async function onSelect(row: ReflectionObject | null) {
|
|||||||
}
|
}
|
||||||
methods.value = b.methods ?? []
|
methods.value = b.methods ?? []
|
||||||
status.value = b.status ?? []
|
status.value = b.status ?? []
|
||||||
|
syncListStatusFromBundle(row.id, status.value)
|
||||||
|
restoreTableCurrentRow(row.id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`加载详情失败:${(err as Error).message}`)
|
ElMessage.error(`加载详情失败:${(err as Error).message}`)
|
||||||
} finally {
|
} finally {
|
||||||
loadingMethods.value = false
|
loadingMethods.value = false
|
||||||
|
detailLoadingForId.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,6 +729,61 @@ function resolveKindForRow(row: ReflectionObject | null): ReflectionKind {
|
|||||||
return ((row?.subKind as ReflectionKind | undefined) ?? props.kind)
|
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() {
|
async function onReloadPlugins() {
|
||||||
reloadingPlugins.value = true
|
reloadingPlugins.value = true
|
||||||
try {
|
try {
|
||||||
@@ -726,6 +914,7 @@ async function onConfirmCreate() {
|
|||||||
await refresh()
|
await refresh()
|
||||||
const row = objects.value.find((o) => o.id === created.id)
|
const row = objects.value.find((o) => o.id === created.id)
|
||||||
if (row) await onSelect(row)
|
if (row) await onSelect(row)
|
||||||
|
await persistProjectAfterMutation()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`创建失败:${(err as Error).message}`)
|
ElMessage.error(`创建失败:${(err as Error).message}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -751,6 +940,7 @@ async function onCreateByTypeName(typeName: string) {
|
|||||||
await refresh()
|
await refresh()
|
||||||
const row = objects.value.find((o) => o.id === created.id)
|
const row = objects.value.find((o) => o.id === created.id)
|
||||||
if (row) await onSelect(row)
|
if (row) await onSelect(row)
|
||||||
|
await persistProjectAfterMutation()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`创建失败:${(err as Error).message}`)
|
ElMessage.error(`创建失败:${(err as Error).message}`)
|
||||||
}
|
}
|
||||||
@@ -773,6 +963,7 @@ async function onDelete(row: ReflectionObject) {
|
|||||||
ElMessage.success(`已删除 ${row.typeName} #${row.id}`)
|
ElMessage.success(`已删除 ${row.typeName} #${row.id}`)
|
||||||
if (current.value?.id === row.id) current.value = null
|
if (current.value?.id === row.id) current.value = null
|
||||||
await refresh()
|
await refresh()
|
||||||
|
await persistProjectAfterMutation()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`删除失败:${(err as Error).message}`)
|
ElMessage.error(`删除失败:${(err as Error).message}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -787,6 +978,7 @@ async function onSetField(key: string, value: string) {
|
|||||||
const realKind = resolveKindForRow(current.value)
|
const realKind = resolveKindForRow(current.value)
|
||||||
await reflectionApi.setField(realKind, current.value.id, key, value)
|
await reflectionApi.setField(realKind, current.value.id, key, value)
|
||||||
ElMessage.success(`已更新 ${key}`)
|
ElMessage.success(`已更新 ${key}`)
|
||||||
|
await persistProjectAfterMutation()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`更新 ${key} 失败:${(err as Error).message}`)
|
ElMessage.error(`更新 ${key} 失败:${(err as Error).message}`)
|
||||||
await onSelect(current.value)
|
await onSelect(current.value)
|
||||||
@@ -807,6 +999,7 @@ async function onDeleteField(key: string) {
|
|||||||
await reflectionApi.deleteField(realKind, current.value.id, key)
|
await reflectionApi.deleteField(realKind, current.value.id, key)
|
||||||
ElMessage.success(`已删除字段 ${key}`)
|
ElMessage.success(`已删除字段 ${key}`)
|
||||||
await onSelect(current.value)
|
await onSelect(current.value)
|
||||||
|
await persistProjectAfterMutation()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`删除字段失败:${(err as Error).message}`)
|
ElMessage.error(`删除字段失败:${(err as Error).message}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -839,14 +1032,18 @@ async function onAddField() {
|
|||||||
await onSelect(current.value)
|
await onSelect(current.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onHighlight3D(row: ReflectionObject | null) {
|
async function onViewInMap(row: ReflectionObject | null) {
|
||||||
if (!row) return
|
if (!row || !viewInMapEnabled.value) return
|
||||||
|
const realKind = resolveKindForRow(row)
|
||||||
|
const focusKind = toMapFocusKind(realKind)
|
||||||
|
if (!focusKind) return
|
||||||
try {
|
try {
|
||||||
const realKind = resolveKindForRow(row)
|
await router.push({
|
||||||
await reflectionApi.setSelection(realKind, row.id)
|
path: MAP_MONITOR_PATH,
|
||||||
ElMessage.success(`已在 3D 视口高亮 ${row.typeName ?? realKind} #${row.id}`)
|
query: buildMapFocusQuery(focusKind, row.id)
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`高亮失败:${(err as Error).message}`)
|
ElMessage.error(`跳转地图失败:${(err as Error).message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,9 +1293,22 @@ onMounted(refresh)
|
|||||||
font-weight: 600;
|
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 {
|
.field-section {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
.field-section--first {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
.field-section-title {
|
.field-section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ReflectionKind } from '@/api/reflection'
|
||||||
|
|
||||||
|
/** 管理页「查看对象」可跳转地图并定位的反射 kind(进程/脚本等逻辑对象除外)。 */
|
||||||
|
export type MapFocusKind = 'car' | 'site' | 'track' | 'special'
|
||||||
|
|
||||||
|
const MAP_VIEWABLE_KINDS = new Set<ReflectionKind>(['car', 'vehicle', 'site', 'track', 'special'])
|
||||||
|
|
||||||
|
export function isMapViewableKind(kind: ReflectionKind): boolean {
|
||||||
|
return MAP_VIEWABLE_KINDS.has(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toMapFocusKind(kind: ReflectionKind): MapFocusKind | null {
|
||||||
|
if (kind === 'vehicle' || kind === 'car') return 'car'
|
||||||
|
if (kind === 'site' || kind === 'track' || kind === 'special') return kind
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAP_MONITOR_PATH = '/admin/map-monitor'
|
||||||
|
|
||||||
|
export function buildMapFocusQuery(kind: MapFocusKind, id: number) {
|
||||||
|
return { focusKind: kind, focusId: String(id) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ReflectionKind } from '@/api/reflection'
|
||||||
|
|
||||||
|
/** 增删改会写入 SimpleProject 内存、需落盘到项目 JSON 的 kind(不含脚本等运行时只读列表)。 */
|
||||||
|
const PROJECT_PERSISTABLE_KINDS = new Set<ReflectionKind>([
|
||||||
|
'car',
|
||||||
|
'site',
|
||||||
|
'track',
|
||||||
|
'special',
|
||||||
|
'process'
|
||||||
|
])
|
||||||
|
|
||||||
|
export function isProjectPersistableKind(kind: ReflectionKind): boolean {
|
||||||
|
return PROJECT_PERSISTABLE_KINDS.has(kind)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { mapsApi } from '@/api/mapEdit'
|
||||||
|
import { reflectionApi } from '@/api/reflection'
|
||||||
|
|
||||||
|
export type SaveMode = 'new' | 'replace' | 'cancel'
|
||||||
|
|
||||||
|
/** 文件名安全的时间戳:yyyyMMdd_HHmmss */
|
||||||
|
export function formatSaveTimestamp(d = new Date()): string {
|
||||||
|
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())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 名称后追加修改日期。 */
|
||||||
|
export function defaultNewSaveName(baseName: string, d = new Date()): string {
|
||||||
|
const trimmed = baseName.trim()
|
||||||
|
if (!trimmed) return formatSaveTimestamp(d)
|
||||||
|
return `${trimmed}_${formatSaveTimestamp(d)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileBaseName(path: string): string {
|
||||||
|
const normalized = path.replace(/\\/g, '/')
|
||||||
|
const file = normalized.slice(normalized.lastIndexOf('/') + 1)
|
||||||
|
const dot = file.lastIndexOf('.')
|
||||||
|
return dot >= 0 ? file.slice(0, dot) : file
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与源路径同目录拼接新文件名。 */
|
||||||
|
export function joinPathWithName(sourcePath: string, newBaseName: string, ext = '.json'): string {
|
||||||
|
const normalized = sourcePath.replace(/\\/g, '/')
|
||||||
|
const slash = normalized.lastIndexOf('/')
|
||||||
|
const sep = sourcePath.includes('\\') ? '\\' : '/'
|
||||||
|
// 统一用归一化后的斜杠下标取目录,再换回平台分隔符;避免混合分隔符(如 C:\foo/bar.json)算错目录。
|
||||||
|
const dir = slash >= 0 ? normalized.slice(0, slash).replace(/\//g, sep) : ''
|
||||||
|
const file = `${newBaseName}${ext}`
|
||||||
|
return dir ? `${dir}${sep}${file}` : file
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveBaseProjectPath(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const pf = await reflectionApi.getProjectFields()
|
||||||
|
if (pf.lastLoadedPath?.trim()) return pf.lastLoadedPath.trim()
|
||||||
|
if (pf.autoloadPath?.trim()) return pf.autoloadPath.trim()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析当前正在使用的地图名(编辑页未带 ?map= 时兜底)。 */
|
||||||
|
export async function resolveCurrentMapName(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const r = await mapsApi.list()
|
||||||
|
const cur = r.maps.find((m) => m.isCurrent)
|
||||||
|
if (cur?.name?.trim()) return cur.name.trim()
|
||||||
|
if (r.currentFileName?.trim()) {
|
||||||
|
return r.currentFileName.replace(/\.json$/i, '').trim()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAVE_NAME_PATTERN = /^[^\\/:*?"<>|]+$/
|
||||||
|
|
||||||
|
/** 是否保存成新项目?否 = 在原项目基础上直接覆盖。 */
|
||||||
|
export async function promptSaveMode(): Promise<SaveMode> {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'是否保存成新项目?\n\n' +
|
||||||
|
'· 否,在原项目基础上保存:名称不变,直接覆盖原文件\n' +
|
||||||
|
'· 是,保存成新项目:在服务器另存(默认名称含修改日期)',
|
||||||
|
'保存确认',
|
||||||
|
{
|
||||||
|
confirmButtonText: '是,保存成新项目',
|
||||||
|
cancelButtonText: '否,在原项目基础上保存',
|
||||||
|
distinguishCancelAndClose: true,
|
||||||
|
type: 'info'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 'new'
|
||||||
|
} catch (action) {
|
||||||
|
if (action === 'cancel') return 'replace'
|
||||||
|
return 'cancel'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 弹窗输入另存名称,默认「原名_修改日期」。 */
|
||||||
|
export async function promptNewSaveName(
|
||||||
|
title: string,
|
||||||
|
baseName: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const r = await ElMessageBox.prompt(`请输入${title}名称`, title, {
|
||||||
|
inputValue: defaultNewSaveName(baseName),
|
||||||
|
inputPattern: SAVE_NAME_PATTERN,
|
||||||
|
inputErrorMessage: '名称不能包含 \\ / : * ? " < > | 等字符',
|
||||||
|
confirmButtonText: '保存到服务器',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
})
|
||||||
|
const picked = (r.value ?? '').trim()
|
||||||
|
return picked || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目:在原路径上覆盖,或另存为新文件(同目录 + 新名称)。 */
|
||||||
|
export async function executeProjectSave(mode: SaveMode): Promise<string | null> {
|
||||||
|
if (mode === 'cancel') return null
|
||||||
|
|
||||||
|
if (mode === 'replace') {
|
||||||
|
const r = await reflectionApi.saveProject()
|
||||||
|
return r.path
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = await resolveBaseProjectPath()
|
||||||
|
const baseName = basePath ? fileBaseName(basePath) : 'project'
|
||||||
|
const newName = await promptNewSaveName('保存成新项目', baseName)
|
||||||
|
if (!newName) return null
|
||||||
|
|
||||||
|
const targetPath = basePath
|
||||||
|
? joinPathWithName(basePath, newName)
|
||||||
|
: `${newName}.json`
|
||||||
|
const r = await reflectionApi.saveProject(targetPath)
|
||||||
|
return r.path
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
kind-label="车辆"
|
kind-label="车辆"
|
||||||
title="车辆管理(Car / AbstractCar,含模拟车、插件车型 reflectionApi 实例化)"
|
title="车辆管理(Car / AbstractCar,含模拟车、插件车型 reflectionApi 实例化)"
|
||||||
empty-text="当前没有车辆;点击右上角「新建车辆」从已加载的 CarType 中选一个实例化。"
|
empty-text="当前没有车辆;点击右上角「新建车辆」从已加载的 CarType 中选一个实例化。"
|
||||||
|
enable-project-save
|
||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="车型样式 / 报警颜色" name="style">
|
<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 { buildAlignOps, type AlignTarget, type AlignMode } from '@/composables/useAlignment'
|
||||||
import { genLinearH, genLinearV, genMatrix, genCircular } from '@/composables/useBatchGenerate'
|
import { genLinearH, genLinearV, genMatrix, genCircular } from '@/composables/useBatchGenerate'
|
||||||
import { mapEditApi, aiConfigApi, mapsApi } from '@/api/mapEdit'
|
import { mapEditApi, aiConfigApi, mapsApi } from '@/api/mapEdit'
|
||||||
|
import {
|
||||||
|
promptNewSaveName,
|
||||||
|
promptSaveMode,
|
||||||
|
resolveCurrentMapName
|
||||||
|
} from '@/utils/projectSaveFlow'
|
||||||
import {
|
import {
|
||||||
reflectionApi,
|
reflectionApi,
|
||||||
normalizeViewportPayload,
|
normalizeViewportPayload,
|
||||||
@@ -1402,30 +1407,47 @@ async function onAiGenerated(r: import('@/api/mapEdit').AiMapGenerateResult) {
|
|||||||
// 项目保存:调反射 API 触发 SimpleProject.Save (如有 MethodMember 暴露)
|
// 项目保存:调反射 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() {
|
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
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
let outcome = await mapsApi.save(name, false)
|
let outcome = await mapsApi.save(name, overwrite)
|
||||||
if (!outcome.ok && outcome.conflict) {
|
|
||||||
|
if (!outcome.ok && outcome.conflict && mode === 'new') {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`地图「${name}」已存在,是否替换原地图文件?`,
|
`服务器已存在地图「${name}」,是否覆盖该文件?`,
|
||||||
'替换确认',
|
'覆盖确认',
|
||||||
{ type: 'warning', confirmButtonText: '替换', cancelButtonText: '取消' }
|
{ type: 'warning', confirmButtonText: '覆盖', cancelButtonText: '取消' }
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
@@ -1438,8 +1460,12 @@ async function saveMapAndLeave() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
editingMapName.value = outcome.data.name
|
if (mode === 'replace') {
|
||||||
ElMessage.success(`已保存地图「${outcome.data.name}」`)
|
ElMessage.success(`已覆盖保存地图「${name}」`)
|
||||||
|
} else {
|
||||||
|
editingMapName.value = outcome.data.name
|
||||||
|
ElMessage.success(`新地图「${outcome.data.name}」已保存到服务器`)
|
||||||
|
}
|
||||||
await router.push({ path: '/admin/maps' })
|
await router.push({ path: '/admin/maps' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`保存失败:${(err as Error).message}`)
|
ElMessage.error(`保存失败:${(err as Error).message}`)
|
||||||
@@ -1649,6 +1675,9 @@ async function loadFromRouteQuery() {
|
|||||||
}
|
}
|
||||||
} else if (isNew) {
|
} else if (isNew) {
|
||||||
editingMapName.value = ''
|
editingMapName.value = ''
|
||||||
|
} else {
|
||||||
|
const current = await resolveCurrentMapName()
|
||||||
|
if (current) editingMapName.value = current
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,40 +27,70 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<el-table
|
<div class="mm-body">
|
||||||
v-loading="loading"
|
<el-card shadow="never" class="mm-list-card">
|
||||||
:data="maps"
|
<el-table
|
||||||
class="map-table"
|
ref="tableRef"
|
||||||
border
|
v-loading="loading"
|
||||||
:row-class-name="rowClassName"
|
:data="maps"
|
||||||
empty-text="固定文件夹内还没有地图,点击右上角「新增地图」创建。"
|
class="map-table"
|
||||||
>
|
border
|
||||||
<el-table-column label="地图名称" min-width="220">
|
highlight-current-row
|
||||||
<template #default="{ row }">
|
row-key="name"
|
||||||
<span class="map-name">{{ row.name }}</span>
|
:row-class-name="rowClassName"
|
||||||
<el-tag v-if="row.isCurrent" size="small" type="success" effect="dark" class="current-tag">
|
empty-text="固定文件夹内还没有地图,点击右上角「新增地图」创建。"
|
||||||
使用中
|
@row-click="onRowClick"
|
||||||
</el-tag>
|
>
|
||||||
|
<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>
|
</template>
|
||||||
</el-table-column>
|
<template v-else>
|
||||||
<el-table-column label="修改时间" prop="modified" width="190" />
|
<div class="mm-detail-head">
|
||||||
<el-table-column label="操作" width="360" align="right">
|
<div class="mm-detail-title">
|
||||||
<template #default="{ row }">
|
<span class="map-name">{{ viewingName }}</span>
|
||||||
<el-button
|
<el-tag v-if="viewingIsCurrent" size="small" type="success" effect="dark">使用中</el-tag>
|
||||||
size="small"
|
</div>
|
||||||
type="primary"
|
<div v-if="viewingPath" class="mm-detail-meta">
|
||||||
:disabled="row.isCurrent"
|
<code>{{ viewingPath }}</code>
|
||||||
:loading="busyName === row.name"
|
</div>
|
||||||
@click="onUse(row)"
|
</div>
|
||||||
>
|
<div v-loading="contentLoading" class="mm-json-wrap">
|
||||||
{{ row.isCurrent ? '使用中' : '使用' }}
|
<JsonFoldViewer v-if="viewingJsonRaw" :raw="viewingJsonRaw" />
|
||||||
</el-button>
|
<el-empty v-else-if="!contentLoading" description="未能加载地图配置" />
|
||||||
<el-button size="small" @click="onRename(row)">重命名</el-button>
|
</div>
|
||||||
<el-button size="small" @click="onEdit(row)">编辑</el-button>
|
|
||||||
<el-button size="small" type="danger" plain @click="onDelete(row)">删除</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-card>
|
||||||
</el-table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
@@ -76,14 +106,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import type { TableInstance } from 'element-plus'
|
||||||
import { mapsApi, type MapListItem } from '@/api/mapEdit'
|
import { mapsApi, type MapListItem } from '@/api/mapEdit'
|
||||||
|
import JsonFoldViewer from '@/components/common/JsonFoldViewer.vue'
|
||||||
import MapConnectionPanel from '@/components/map-manage/MapConnectionPanel.vue'
|
import MapConnectionPanel from '@/components/map-manage/MapConnectionPanel.vue'
|
||||||
import MapMergePanel from '@/components/map-manage/MapMergePanel.vue'
|
import MapMergePanel from '@/components/map-manage/MapMergePanel.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const tableRef = ref<TableInstance>()
|
||||||
|
|
||||||
const activeTab = ref<'maps' | 'connections' | 'merge'>('maps')
|
const activeTab = ref<'maps' | 'connections' | 'merge'>('maps')
|
||||||
|
|
||||||
@@ -98,8 +131,47 @@ const directory = ref('')
|
|||||||
const currentName = ref('')
|
const currentName = ref('')
|
||||||
const busyName = 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 }) {
|
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() {
|
async function refresh() {
|
||||||
@@ -110,6 +182,19 @@ async function refresh() {
|
|||||||
directory.value = r.directory
|
directory.value = r.directory
|
||||||
const cur = r.maps.find((m) => m.isCurrent)
|
const cur = r.maps.find((m) => m.isCurrent)
|
||||||
currentName.value = cur?.name ?? ''
|
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) {
|
} catch (err) {
|
||||||
ElMessage.error(`加载地图列表失败:${(err as Error).message}`)
|
ElMessage.error(`加载地图列表失败:${(err as Error).message}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -180,6 +265,7 @@ async function onRename(row: MapListItem) {
|
|||||||
|
|
||||||
busyName.value = row.name
|
busyName.value = row.name
|
||||||
await mapsApi.rename(row.name, to)
|
await mapsApi.rename(row.name, to)
|
||||||
|
if (viewingName.value === row.name) viewingName.value = to
|
||||||
ElMessage.success(`已重命名为「${to}」`)
|
ElMessage.success(`已重命名为「${to}」`)
|
||||||
await refresh()
|
await refresh()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -198,6 +284,12 @@ async function onDelete(row: MapListItem) {
|
|||||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
|
||||||
)
|
)
|
||||||
await mapsApi.delete(row.name)
|
await mapsApi.delete(row.name)
|
||||||
|
if (viewingName.value === row.name) {
|
||||||
|
viewingName.value = ''
|
||||||
|
viewingJsonRaw.value = ''
|
||||||
|
viewingPath.value = ''
|
||||||
|
tableRef.value?.setCurrentRow(undefined)
|
||||||
|
}
|
||||||
ElMessage.success(`已删除「${row.name}」`)
|
ElMessage.success(`已删除「${row.name}」`)
|
||||||
await refresh()
|
await refresh()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -257,10 +349,43 @@ onMounted(refresh)
|
|||||||
background: var(--mg-veil-2);
|
background: var(--mg-veil-2);
|
||||||
color: var(--mg-text-light);
|
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 {
|
.map-table {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-table :deep(.el-table__row) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.map-name {
|
.map-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--mg-text-light);
|
color: var(--mg-text-light);
|
||||||
@@ -276,4 +401,35 @@ onMounted(refresh)
|
|||||||
.map-table :deep(.current-row):hover > td.el-table__cell {
|
.map-table :deep(.current-row):hover > td.el-table__cell {
|
||||||
background: rgba(var(--mg-status-success-rgb), 0.24) !important;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
kind-label="进程"
|
kind-label="进程"
|
||||||
title="进程管理(Mission / Process)"
|
title="进程管理(Mission / Process)"
|
||||||
empty-text="当前没有进程;点击右上角「新建进程」即可基于已加载的 MissionType 实例化一个。"
|
empty-text="当前没有进程;点击右上角「新建进程」即可基于已加载的 MissionType 实例化一个。"
|
||||||
|
show-status-column
|
||||||
|
status-label="状态"
|
||||||
|
status-reflection-key="status"
|
||||||
|
enable-project-save
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
kind-label="站点"
|
kind-label="站点"
|
||||||
title="站点管理(Site / UISite,含禁用/启用、必空点等动作)"
|
title="站点管理(Site / UISite,含禁用/启用、必空点等动作)"
|
||||||
empty-text="当前没有站点;点击右上角「新建站点」按 x/y 坐标添加。"
|
empty-text="当前没有站点;点击右上角「新建站点」按 x/y 坐标添加。"
|
||||||
|
enable-project-save
|
||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="路径" name="track">
|
<el-tab-pane label="路径" name="track">
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
kind-label="路径"
|
kind-label="路径"
|
||||||
title="路径管理(Track / UITrack,含方向、冲突、投影、二分等动作)"
|
title="路径管理(Track / UITrack,含方向、冲突、投影、二分等动作)"
|
||||||
empty-text="当前没有路径;点击右上角「新建路径」选起止站点添加。"
|
empty-text="当前没有路径;点击右上角「新建路径」选起止站点添加。"
|
||||||
|
enable-project-save
|
||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="装饰物" name="special">
|
<el-tab-pane label="装饰物" name="special">
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
kind-label="装饰物"
|
kind-label="装饰物"
|
||||||
title="装饰物管理(UI_Image / UI_Text / UI_Model)"
|
title="装饰物管理(UI_Image / UI_Text / UI_Model)"
|
||||||
empty-text="当前没有装饰物;新建图片/文本/模型可用底部按钮,或先在地图编辑里上传资产。"
|
empty-text="当前没有装饰物;新建图片/文本/模型可用底部按钮,或先在地图编辑里上传资产。"
|
||||||
|
enable-project-save
|
||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
平台「脚本管理」= SimpleLite 工作台「脚本」页(ComposerDockPanel.RenderScriptTable)的 Web 镜像:
|
平台「脚本管理」= SimpleLite 工作台「脚本」页(ComposerDockPanel.RenderScriptTable)的 Web 镜像:
|
||||||
- 列表 = CarProgram.GetPrograms(),最多 300 条(含历史),由 Mission 调度运行时自动产生与回收。
|
- 列表 = CarProgram.GetPrograms(),最多 300 条(含历史),由 Mission 调度运行时自动产生与回收。
|
||||||
- 列:ID / 名称(任务名) / 类型(CarProgram 子类) / 车辆(plans[0].usingCar) / 状态(ProgramStatus.state)。
|
- 列:ID / 名称(任务名) / 类型(CarProgram 子类) / 车辆(plans[0].usingCar) / 状态(ProgramStatus.state)。
|
||||||
- 不渲染「新建」按钮:CarProgram 无法手动 new,要新增脚本应去「任务编排」页建 Mission。
|
- 不渲染「新建」按钮:CarProgram 无法手动 new,由 Mission 调度运行时自动产生(在 SimpleLite 工作台新建 Mission 后生成)。
|
||||||
- 不渲染「删除」按钮:CarProgram 由调度器在环形队列里维护,删除一律会被后端拒。
|
- 不渲染「删除」按钮:CarProgram 由调度器在环形队列里维护,删除一律会被后端拒。
|
||||||
右侧详情面板沿用通用 ReflectionManagerPanel 渲染(fields 多为 typed/locked,只读 status 通过反射)。
|
右侧详情面板沿用通用 ReflectionManagerPanel 渲染(fields 多为 typed/locked,只读 status 通过反射)。
|
||||||
-->
|
-->
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
kind="script"
|
kind="script"
|
||||||
kind-label="脚本"
|
kind-label="脚本"
|
||||||
title="脚本管理(CarProgram 运行实例 · 与 SimpleLite 工作台「脚本」页对齐)"
|
title="脚本管理(CarProgram 运行实例 · 与 SimpleLite 工作台「脚本」页对齐)"
|
||||||
empty-text="当前无 CarProgram 实例。CarProgram 是 Mission 运行时编译用户脚本后产生的对象,要创建脚本请到「任务编排」页新建 Mission。"
|
empty-text="当前无 CarProgram 实例。CarProgram 是 Mission 运行时编译用户脚本后产生的对象,请在 SimpleLite 工作台新建 Mission(任务)后由调度运行时自动生成。"
|
||||||
disable-create
|
disable-create
|
||||||
disable-delete
|
disable-delete
|
||||||
show-summary-column
|
show-summary-column
|
||||||
|
|||||||
Reference in New Issue
Block a user