diff --git a/frontends/apps/simple-platform-vue/src/api/mapEdit.ts b/frontends/apps/simple-platform-vue/src/api/mapEdit.ts index 4f3bb04..9f11eb1 100644 --- a/frontends/apps/simple-platform-vue/src/api/mapEdit.ts +++ b/frontends/apps/simple-platform-vue/src/api/mapEdit.ts @@ -1,3 +1,4 @@ +import type { AxiosError } from 'axios' import http from './http' /** @@ -331,6 +332,13 @@ export interface MapSaveResult { savedAt: string } +export interface MapContentResult { + name: string + fileName: string + path: string + content: string +} + /** 保存结果:success 正常返回 data;conflict=true 表示同名地图已存在,调用方应弹「替换」确认。 */ export type MapSaveOutcome = | { ok: true; data: MapSaveResult } @@ -363,6 +371,12 @@ export type MapMergeOutcome = export const mapsApi = { list: () => unwrap(http.get(`${BASE}/maps`)), + /** 读取固定地图文件夹内某张地图的 JSON 原文(MiGu.Server MapsContentController)。 */ + readContent: (name: string) => + http.get( + `/maps/${encodeURIComponent(name)}/content` + ).then((r) => r.data), + sceneTaskStatus: () => unwrap(http.get(`${BASE}/maps/scene-task-status`)), /** @@ -370,9 +384,18 @@ export const mapsApi = { * 这里翻译为 { ok:false, conflict:true },让调用方弹「是否替换原地图」确认框。 */ async save(name: string, overwrite = false): Promise { - const { data } = await http.post>(`${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 ?? '保存失败' } + try { + const { data } = await http.post>(`${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 ?? '保存失败' } + } catch (err) { + const ax = err as AxiosError> + const body = ax.response?.data + if (ax.response?.status === 409 || body?.code === 409) { + return { ok: false, conflict: true, message: body?.message ?? '地图已存在' } + } + throw err + } }, /** 加载指定地图到场景以供编辑(不校验任务、不改当前使用地图)。 */ diff --git a/frontends/apps/simple-platform-vue/src/components/reflection/ReflectionManagerPanel.vue b/frontends/apps/simple-platform-vue/src/components/reflection/ReflectionManagerPanel.vue index 20961a3..ec9b7b1 100644 --- a/frontends/apps/simple-platform-vue/src/components/reflection/ReflectionManagerPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/reflection/ReflectionManagerPanel.vue @@ -59,6 +59,21 @@ 刷新 + + + {{ saveButtonLabel }} + + @@ -66,8 +81,10 @@ @@ -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(null) const deletingField = ref(null) const savingField = ref(null) const reloadingPlugins = ref(false) +const savingProject = ref(false) +const lastProjectSavePath = ref(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 | null>(null) +const tableRef = ref(null) +const detailLoadingForId = ref(null) const objects = ref([]) const creatableTypes = ref([]) const current = ref(null) +const detailTab = ref('properties') interface MutableKv { key: string; value: string; locked?: boolean; source?: 'typed' | 'dynamic'; typeName?: string } const allFields = ref([]) const status = ref([]) @@ -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(['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 { + 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 { + 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; diff --git a/frontends/apps/simple-platform-vue/src/utils/mapObjectFocus.ts b/frontends/apps/simple-platform-vue/src/utils/mapObjectFocus.ts new file mode 100644 index 0000000..987df21 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/utils/mapObjectFocus.ts @@ -0,0 +1,22 @@ +import type { ReflectionKind } from '@/api/reflection' + +/** 管理页「查看对象」可跳转地图并定位的反射 kind(进程/脚本等逻辑对象除外)。 */ +export type MapFocusKind = 'car' | 'site' | 'track' | 'special' + +const MAP_VIEWABLE_KINDS = new Set(['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) } +} diff --git a/frontends/apps/simple-platform-vue/src/utils/projectPersistence.ts b/frontends/apps/simple-platform-vue/src/utils/projectPersistence.ts new file mode 100644 index 0000000..39d7fc0 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/utils/projectPersistence.ts @@ -0,0 +1,14 @@ +import type { ReflectionKind } from '@/api/reflection' + +/** 增删改会写入 SimpleProject 内存、需落盘到项目 JSON 的 kind(不含脚本等运行时只读列表)。 */ +const PROJECT_PERSISTABLE_KINDS = new Set([ + 'car', + 'site', + 'track', + 'special', + 'process' +]) + +export function isProjectPersistableKind(kind: ReflectionKind): boolean { + return PROJECT_PERSISTABLE_KINDS.has(kind) +} diff --git a/frontends/apps/simple-platform-vue/src/utils/projectSaveFlow.ts b/frontends/apps/simple-platform-vue/src/utils/projectSaveFlow.ts new file mode 100644 index 0000000..b4313fa --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/utils/projectSaveFlow.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/frontends/apps/simple-platform-vue/src/views/admin/CarPanelView.vue b/frontends/apps/simple-platform-vue/src/views/admin/CarPanelView.vue index 77103bb..26a391b 100644 --- a/frontends/apps/simple-platform-vue/src/views/admin/CarPanelView.vue +++ b/frontends/apps/simple-platform-vue/src/views/admin/CarPanelView.vue @@ -8,6 +8,7 @@ kind-label="车辆" title="车辆管理(Car / AbstractCar,含模拟车、插件车型 reflectionApi 实例化)" empty-text="当前没有车辆;点击右上角「新建车辆」从已加载的 CarType 中选一个实例化。" + enable-project-save /> diff --git a/frontends/apps/simple-platform-vue/src/views/admin/MapEditorView.vue b/frontends/apps/simple-platform-vue/src/views/admin/MapEditorView.vue index e3db4d3..74cc0f5 100644 --- a/frontends/apps/simple-platform-vue/src/views/admin/MapEditorView.vue +++ b/frontends/apps/simple-platform-vue/src/views/admin/MapEditorView.vue @@ -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 } } diff --git a/frontends/apps/simple-platform-vue/src/views/admin/MapManagementView.vue b/frontends/apps/simple-platform-vue/src/views/admin/MapManagementView.vue index 5b1856e..9733881 100644 --- a/frontends/apps/simple-platform-vue/src/views/admin/MapManagementView.vue +++ b/frontends/apps/simple-platform-vue/src/views/admin/MapManagementView.vue @@ -27,40 +27,70 @@ - - -