feat(map-editor): 站点/路径视口样式实时编辑与画布快捷键联动
- reflection API 新增 viewport-style GET/PATCH 及 ARGB 无符号化工具 - 属性面板「类型默认」重构为选中对象的视口样式实时编辑(防抖 + 序号防竞态) - 画布 iframe 聚焦时经 SSE 转发撤销/重做/删除,移除冗余的坐标添加菜单 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -136,6 +136,11 @@ function onMessage(ev: MessageEvent) {
|
||||
} else if (msg.type === 'workspace.select' && Array.isArray(msg.payload)) {
|
||||
lastSelect.value = msg.payload as string[]
|
||||
emit('select', msg.payload as string[])
|
||||
} else if (msg.type === 'workspace.shortcut' && msg.payload && typeof msg.payload === 'object') {
|
||||
const action = (msg.payload as { action?: string }).action
|
||||
if (action === 'undo' || action === 'redo' || action === 'delete') {
|
||||
window.dispatchEvent(new CustomEvent('workspace-shortcut', { detail: { action } }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+240
-26
@@ -220,32 +220,181 @@
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="类型默认" name="defaults">
|
||||
<el-form label-width="120px" size="small" class="defaults-form">
|
||||
<el-alert type="info" :closable="false">
|
||||
为「新建站点 / 新建路径」预设默认样式与字段。保存后下次创建即生效。
|
||||
<div class="defaults-pane">
|
||||
<el-alert type="info" :closable="false" class="defaults-alert">
|
||||
视口样式与 SimpleLite「视口样式」对话框同步,修改后即时重绘画布;下方「新建默认」仅影响下次创建对象。
|
||||
</el-alert>
|
||||
<el-divider content-position="left">站点</el-divider>
|
||||
<el-form-item label="默认图层">
|
||||
<el-input v-model="defaults.site.layer" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名字前缀">
|
||||
<el-input v-model="defaults.site.namePrefix" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认颜色">
|
||||
<el-color-picker v-model="defaults.site.color" show-alpha />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">路径</el-divider>
|
||||
<el-form-item label="默认图层">
|
||||
<el-input v-model="defaults.track.layer" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认线宽">
|
||||
<el-input-number v-model="defaults.track.lineWidth" :min="1" :max="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认颜色">
|
||||
<el-color-picker v-model="defaults.track.color" show-alpha />
|
||||
</el-form-item>
|
||||
<el-button size="small" type="primary" @click="onSaveDefaults">保存默认</el-button>
|
||||
</el-form>
|
||||
|
||||
<div v-if="!selectionKind" class="empty-hint defaults-empty">
|
||||
<el-icon class="empty-icon"><InfoFilled /></el-icon>
|
||||
<span class="empty-title">请选中站点或路径</span>
|
||||
<span class="empty-desc">在画布上选中一个站点或路径后,可在此调整对应视口样式。</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="selectionKind === 'site' && localViewportSite">
|
||||
<section class="prop-card prop-card--style">
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">站点视口样式</span>
|
||||
<span class="prop-card-flag" :class="{ 'is-synced': viewportSynced }">
|
||||
<span class="prop-card-flag-dot" />
|
||||
<span class="prop-card-flag-text">{{ viewportSyncing ? '同步中…' : viewportSynced ? '已同步' : '实时同步' }}</span>
|
||||
</span>
|
||||
</header>
|
||||
<el-form label-width="132px" size="small" class="defaults-form">
|
||||
<el-form-item label="整体大小倍率">
|
||||
<el-input-number
|
||||
:model-value="localViewportSite.drawScale"
|
||||
:min="0.25"
|
||||
:max="12"
|
||||
:step="0.1"
|
||||
:controls="true"
|
||||
@update:model-value="(v: number | undefined) => patchSite({ drawScale: v ?? localViewportSite!.drawScale })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="未选中圆点半径">
|
||||
<el-input-number
|
||||
:model-value="localViewportSite.dotRadiusNormal"
|
||||
:min="1"
|
||||
:max="24"
|
||||
:step="0.5"
|
||||
@update:model-value="(v: number | undefined) => patchSite({ dotRadiusNormal: v ?? localViewportSite!.dotRadiusNormal })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="选中圆点半径">
|
||||
<el-input-number
|
||||
:model-value="localViewportSite.dotRadiusSelected"
|
||||
:min="1"
|
||||
:max="32"
|
||||
:step="0.5"
|
||||
@update:model-value="(v: number | undefined) => patchSite({ dotRadiusSelected: v ?? localViewportSite!.dotRadiusSelected })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="选中环半径 (m)">
|
||||
<el-input-number
|
||||
:model-value="localViewportSite.selectionRingM"
|
||||
:min="0.02"
|
||||
:max="0.8"
|
||||
:step="0.01"
|
||||
@update:model-value="(v: number | undefined) => patchSite({ selectionRingM: v ?? localViewportSite!.selectionRingM })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="站点颜色(未选中)">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(localViewportSite.colorNormalArgb)"
|
||||
@update:model-value="(v: string | null) => patchSite({ colorNormalArgb: hexToArgb(v ?? '#ffffff') })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="站点颜色(选中)">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(localViewportSite.colorSelectedArgb)"
|
||||
@update:model-value="(v: string | null) => patchSite({ colorSelectedArgb: hexToArgb(v ?? '#ff0000') })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签颜色(未选中)">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(localViewportSite.labelColorNormalArgb)"
|
||||
@update:model-value="(v: string | null) => patchSite({ labelColorNormalArgb: hexToArgb(v ?? '#ffffff') })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签颜色(选中)">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(localViewportSite.labelColorSelectedArgb)"
|
||||
@update:model-value="(v: string | null) => patchSite({ labelColorSelectedArgb: hexToArgb(v ?? '#ff0000') })"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</section>
|
||||
<section class="prop-card">
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">新建站点默认</span>
|
||||
</header>
|
||||
<el-form label-width="132px" size="small" class="defaults-form">
|
||||
<el-form-item label="默认图层">
|
||||
<el-input v-model="defaults.site.layer" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名字前缀">
|
||||
<el-input v-model="defaults.site.namePrefix" />
|
||||
</el-form-item>
|
||||
<el-button size="small" type="primary" plain @click="onSaveDefaults">保存新建默认</el-button>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else-if="selectionKind === 'track' && localViewportTrack">
|
||||
<section class="prop-card prop-card--style">
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">路径视口样式</span>
|
||||
<span class="prop-card-flag" :class="{ 'is-synced': viewportSynced }">
|
||||
<span class="prop-card-flag-dot" />
|
||||
<span class="prop-card-flag-text">{{ viewportSyncing ? '同步中…' : viewportSynced ? '已同步' : '实时同步' }}</span>
|
||||
</span>
|
||||
</header>
|
||||
<el-form label-width="132px" size="small" class="defaults-form">
|
||||
<el-form-item label="线宽倍率">
|
||||
<el-input-number
|
||||
:model-value="localViewportTrack.drawScale"
|
||||
:min="0.25"
|
||||
:max="12"
|
||||
:step="0.1"
|
||||
@update:model-value="(v: number | undefined) => patchTrack({ drawScale: v ?? localViewportTrack!.drawScale })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="未选中线宽">
|
||||
<el-input-number
|
||||
:model-value="localViewportTrack.widthNormal"
|
||||
:min="0.4"
|
||||
:max="8"
|
||||
:step="0.1"
|
||||
@update:model-value="(v: number | undefined) => patchTrack({ widthNormal: v ?? localViewportTrack!.widthNormal })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="选中线宽">
|
||||
<el-input-number
|
||||
:model-value="localViewportTrack.widthSelected"
|
||||
:min="0.4"
|
||||
:max="12"
|
||||
:step="0.1"
|
||||
@update:model-value="(v: number | undefined) => patchTrack({ widthSelected: v ?? localViewportTrack!.widthSelected })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示方向箭头">
|
||||
<el-switch
|
||||
:model-value="localViewportTrack.showDirectionArrows"
|
||||
@update:model-value="(v: string | number | boolean) => patchTrack({ showDirectionArrows: Boolean(v) })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="路径颜色(未选中)">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(localViewportTrack.colorNormalArgb)"
|
||||
@update:model-value="(v: string | null) => patchTrack({ colorNormalArgb: hexToArgb(v ?? '#00ffff') })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="路径颜色(选中)">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(localViewportTrack.colorSelectedArgb)"
|
||||
@update:model-value="(v: string | null) => patchTrack({ colorSelectedArgb: hexToArgb(v ?? '#ff0000') })"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</section>
|
||||
<section class="prop-card">
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">新建路径默认</span>
|
||||
</header>
|
||||
<el-form label-width="132px" size="small" class="defaults-form">
|
||||
<el-form-item label="默认图层">
|
||||
<el-input v-model="defaults.track.layer" />
|
||||
</el-form-item>
|
||||
<el-button size="small" type="primary" plain @click="onSaveDefaults">保存新建默认</el-button>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else-if="viewportStyleLoading" class="loading-hint">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载视口样式…</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="图层" name="layers">
|
||||
@@ -280,7 +429,15 @@
|
||||
<script setup lang="ts">
|
||||
import { InfoFilled, Loading } from '@element-plus/icons-vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { ReflectionMethod, ReflectionObject } from '@/api/reflection'
|
||||
import {
|
||||
argbToHex,
|
||||
hexToArgb,
|
||||
type ReflectionMethod,
|
||||
type ReflectionObject,
|
||||
type ViewportStyleSite,
|
||||
type ViewportStyleTrack,
|
||||
type ViewportStylePayload
|
||||
} from '@/api/reflection'
|
||||
|
||||
interface PropFieldKv { key: string; value: string }
|
||||
interface LayerRow { name: string; visible: boolean; selectable: boolean; color: string }
|
||||
@@ -298,6 +455,10 @@ const props = defineProps<{
|
||||
track: { layer: string; lineWidth: number; color: string }
|
||||
}
|
||||
layerRows: LayerRow[]
|
||||
selectionKind: 'site' | 'track' | null
|
||||
viewportStyle: ViewportStylePayload | null
|
||||
viewportStyleLoading?: boolean
|
||||
viewportSyncing?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -310,9 +471,58 @@ const emit = defineEmits<{
|
||||
(e: 'layer-toggle', row: LayerRow): void
|
||||
(e: 'add-layer', name: string): void
|
||||
(e: 'delete'): void
|
||||
(e: 'patch-viewport', patch: { site?: ViewportStyleSite; track?: ViewportStyleTrack }): void
|
||||
}>()
|
||||
|
||||
const tab = ref<'object' | 'defaults' | 'layers'>('object')
|
||||
const viewportSynced = ref(false)
|
||||
let viewportSyncedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const localViewportSite = ref<ViewportStyleSite | null>(null)
|
||||
const localViewportTrack = ref<ViewportStyleTrack | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.viewportStyle,
|
||||
(v) => {
|
||||
if (!v) {
|
||||
localViewportSite.value = null
|
||||
localViewportTrack.value = null
|
||||
return
|
||||
}
|
||||
localViewportSite.value = { ...v.site }
|
||||
localViewportTrack.value = { ...v.track }
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.selectionKind,
|
||||
(k) => {
|
||||
if (k === 'site' || k === 'track') tab.value = 'defaults'
|
||||
}
|
||||
)
|
||||
|
||||
function flashViewportSynced() {
|
||||
viewportSynced.value = true
|
||||
if (viewportSyncedTimer) clearTimeout(viewportSyncedTimer)
|
||||
viewportSyncedTimer = setTimeout(() => { viewportSynced.value = false }, 1400)
|
||||
}
|
||||
|
||||
watch(() => props.viewportSyncing, (busy, wasBusy) => {
|
||||
if (wasBusy && !busy) flashViewportSynced()
|
||||
})
|
||||
|
||||
function patchSite(partial: Partial<ViewportStyleSite>) {
|
||||
if (!localViewportSite.value) return
|
||||
localViewportSite.value = { ...localViewportSite.value, ...partial }
|
||||
emit('patch-viewport', { site: { ...localViewportSite.value } })
|
||||
}
|
||||
|
||||
function patchTrack(partial: Partial<ViewportStyleTrack>) {
|
||||
if (!localViewportTrack.value) return
|
||||
localViewportTrack.value = { ...localViewportTrack.value, ...partial }
|
||||
emit('patch-viewport', { track: { ...localViewportTrack.value } })
|
||||
}
|
||||
|
||||
const actionBusy = ref<string | null>(null)
|
||||
const newLayerName = ref('')
|
||||
const deleting = ref(false)
|
||||
@@ -845,7 +1055,11 @@ function onDelete() {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.defaults-pane { display: flex; flex-direction: column; gap: 10px; }
|
||||
.defaults-alert { margin-bottom: 4px; }
|
||||
.defaults-empty { padding: 40px 12px; }
|
||||
.defaults-form .el-button { margin-top: 8px; }
|
||||
.defaults-form :deep(.el-form-item) { margin-bottom: 10px; }
|
||||
.layer-table { margin: 6px 0; }
|
||||
.new-layer { margin-top: 8px; }
|
||||
</style>
|
||||
|
||||
@@ -42,25 +42,6 @@
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!--
|
||||
「添加」菜单:直接通过坐标 / 站点 ID 落地,不依赖画布 GetPoint。
|
||||
场景:iframe 模式下 SimpleUI.GetPoint 仅监听 LocalTerminal 时(或其他原因画布 pick 不响应),
|
||||
用户可走这条菜单先把站点 / 路径 / 模拟车造出来。
|
||||
-->
|
||||
<el-dropdown trigger="click" @command="onCmd">
|
||||
<el-button text class="topbar-btn">添加</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="add.site.byCoord">站点 (输入 x, y)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.track.polyline.byIds" divided>直线路径 (输入 siteA, siteB)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.track.bezier.byIds">贝塞尔路径 (siteA/B + 控制点坐标)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.track.nurbs.byIds">NURBS 路径 (siteA/B + 控制点坐标)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.track.arc.byIds">弧形路径 (siteA/B + 半径)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.car.dummy.byCoord" divided>模拟车 (输入 x, y, 朝向)</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<el-dropdown trigger="click" @command="onCmd">
|
||||
<el-button text class="topbar-btn">撤销</el-button>
|
||||
<template #dropdown>
|
||||
|
||||
Reference in New Issue
Block a user