feat(map-editor): 站点/路径视口样式实时编辑与画布快捷键联动

- reflection API 新增 viewport-style GET/PATCH 及 ARGB 无符号化工具
- 属性面板「类型默认」重构为选中对象的视口样式实时编辑(防抖 + 序号防竞态)
- 画布 iframe 聚焦时经 SSE 转发撤销/重做/删除,移除冗余的坐标添加菜单

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-05-31 00:16:46 +08:00
co-authored by Cursor
parent 62943e6c42
commit 061acad9de
7 changed files with 580 additions and 214 deletions
@@ -40,6 +40,10 @@
:layers="knownLayers"
:defaults="defaults"
:layer-rows="layerRows"
:selection-kind="selectionKind"
:viewport-style="viewportStyle"
:viewport-style-loading="viewportStyleLoading"
:viewport-syncing="viewportSyncing"
@rename="onRename"
@change-layer="onChangeLayer"
@set-field="onSetField"
@@ -49,6 +53,7 @@
@layer-toggle="onLayerToggle"
@add-layer="onAddLayer"
@delete="deleteSelection"
@patch-viewport="onPatchViewport"
/>
</div>
@@ -150,7 +155,16 @@ 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 } from '@/api/mapEdit'
import { reflectionApi, type ReflectionMethod, type ReflectionObject, type ReflectionKind } from '@/api/reflection'
import {
reflectionApi,
normalizeViewportPayload,
type ReflectionMethod,
type ReflectionObject,
type ReflectionKind,
type ViewportStylePayload,
type ViewportStyleSite,
type ViewportStyleTrack
} from '@/api/reflection'
const auth = useAuthStore()
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
@@ -190,6 +204,22 @@ const defaults = reactive({
track: { layer: 'g', lineWidth: 2, color: '#88ccff' }
})
const viewportStyle = ref<ViewportStylePayload | null>(null)
const viewportStyleLoading = ref(false)
const viewportSyncing = ref(false)
let viewportPatchTimer: ReturnType<typeof setTimeout> | null = null
let viewportPatchSeq = 0
let pendingViewportPatch: { site?: ViewportStyleSite; track?: ViewportStyleTrack } = {}
/** 单选且为 site/track 时驱动「类型默认」面板;多选或车型等返回 null。 */
const selectionKind = computed<'site' | 'track' | null>(() => {
const items = selection.items.value
if (items.length !== 1) return null
const k = items[0]!.kind
if (k === 'site' || k === 'track') return k
return null
})
const knownLayers = ref<string[]>(['g'])
interface LayerRow { name: string; visible: boolean; selectable: boolean; color: string }
const layerRows = ref<LayerRow[]>([
@@ -358,13 +388,6 @@ async function onTopBarCmd(cmd: string) {
case 'view.fitSelection': ElMessage.info('请在 3D 视口右键菜单使用「适配选中」'); break
case 'snap.toGrid': editTool.toggleSnap('grid'); break
case 'snap.toObject': editTool.toggleSnap('object'); break
// 「添加」菜单:用坐标 / ID 直接落地,不依赖画布 GetPoint。
case 'add.site.byCoord': await promptAddSiteByCoord(); break
case 'add.track.polyline.byIds': await promptAddTrackByIds('track'); break
case 'add.track.bezier.byIds': await promptAddTrackByIds('bezier'); break
case 'add.track.nurbs.byIds': await promptAddTrackByIds('nurbs'); break
case 'add.track.arc.byIds': await promptAddTrackByIds('arc'); break
case 'add.car.dummy.byCoord': await promptAddDummyCarByCoord(); break
// 「图层」菜单:新建图层 / 切换图层显示
case 'layer.new': await promptCreateLayer(); break
case 'layer.toggleVisibility': layerVisibilityDialogOpen.value = true; break
@@ -427,73 +450,6 @@ async function toggleViewFilter<G extends keyof ViewFilterState>(
}
}
// ──────────────────────────────────────────────────────────────────────────
// 「添加」菜单:坐标 / ID 直接落地(绕过画布 pick,覆盖 GetPoint 不响应等场景)
// ──────────────────────────────────────────────────────────────────────────
async function promptAddSiteByCoord() {
try {
const x = Number((await ElMessageBox.prompt('X 坐标 (mm)', '添加站点', { inputValue: '0' })).value)
const y = Number((await ElMessageBox.prompt('Y 坐标 (mm)', '添加站点', { inputValue: '0' })).value)
if (!Number.isFinite(x) || !Number.isFinite(y)) {
ElMessage.warning('坐标必须是数字')
return
}
await runCreateSite(x, y)
} catch { /* user cancelled */ }
}
async function promptAddTrackByIds(kind: 'track' | 'bezier' | 'arc' | 'nurbs') {
const label = trackKindLabel(kind)
try {
const a = Math.floor(Number((await ElMessageBox.prompt('起点站 siteA (id)', `添加${label}`, { inputValue: '' })).value))
const b = Math.floor(Number((await ElMessageBox.prompt('终点站 siteB (id)', `添加${label}`, { inputValue: '' })).value))
if (!Number.isInteger(a) || !Number.isInteger(b) || a <= 0 || b <= 0) {
ElMessage.warning('siteA / siteB 必须是正整数')
return
}
if (a === b) { ElMessage.warning('起止站点不能相同'); return }
drawTrackState.value.siteA = null
// 收集 typeInfo(贝塞尔 / NURBS:连续输入控制点坐标;弧线:输入半径自动算圆心)。
// 失败 / 取消 → typeInfo = null,下面分两种情况:
// - 用户主动取消 → 直接 return(不创建 track
// - 取不到 site 坐标兜底 → 仍然创建 track 但不带 typeInfo(与旧行为一致)
let typeInfo: string | undefined
let extraLabel = ''
if (kind === 'bezier' || kind === 'nurbs') {
const r = await collectCurveTypeInfoByCoord(a, b, kind)
if (r === 'cancelled') return
if (r != null) {
typeInfo = r.typeInfo
extraLabel = `(控制点 ${r.midCount}`
}
} else if (kind === 'arc') {
const r = await collectArcTypeInfoByCoord(a, b)
if (r === 'cancelled') return
if (r != null) {
typeInfo = r.typeInfo
extraLabel = `(半径 ${r.radius.toFixed(0)}`
}
}
let lastId = -1
await history.run({
label: `新建${label} (${a}${b})${extraLabel}`,
apply: async () => {
const payload: Record<string, unknown> = { siteA: a, siteB: b, layer: defaults.track.layer }
if (typeInfo) payload.typeInfo = typeInfo
const r = await mapEditApi.createTrack(kind, payload as { siteA: number; siteB: number })
lastId = r.id
},
revert: async () => {
if (lastId > 0) await mapEditApi.deleteObject('track', lastId)
}
})
notifyTrackCreatedDefaults(kind)
} catch { /* user cancelled */ }
}
function trackKindLabel(kind: 'track' | 'bezier' | 'arc' | 'nurbs'): string {
return kind === 'bezier' ? '贝塞尔路径'
: kind === 'nurbs' ? 'NURBS 路径'
@@ -502,68 +458,7 @@ function trackKindLabel(kind: 'track' | 'bezier' | 'arc' | 'nurbs'): string {
}
/**
* 在「按 ID 添加」场景下收集贝塞尔 / NURBS 的中间控制点坐标,并拼出 typeInfo 字符串
*
* 返回值:
* - { typeInfo, midCount }:成功
* - null:拉不到 site 坐标,调用方仍可创建 track 但不带 typeInfo
* - 'cancelled':用户在某个对话框点了取消,调用方应直接 return(不创建 track
*/
async function collectCurveTypeInfoByCoord(
a: number,
b: number,
kind: 'bezier' | 'nurbs'
): Promise<{ typeInfo: string; midCount: number } | null | 'cancelled'> {
const sa = await fetchSiteXY(a)
const sb = await fetchSiteXY(b)
if (!sa || !sb) {
ElMessage.warning(`无法拉到 site #${a} / #${b} 的 x,y,将不带 typeInfo 创建 track(控制点会用兜底直线)`)
return null
}
const label = trackKindLabel(kind)
let midCount: number
try {
const v = await ElMessageBox.prompt(
'请输入中间控制点个数(不含起止站点;默认 2)',
`添加${label}`,
{ inputValue: '2', inputPattern: /^\d+$/, inputErrorMessage: '请输入非负整数' }
)
midCount = Math.max(0, Math.floor(Number(v.value)))
} catch { return 'cancelled' }
const midPts: XYPoint[] = []
for (let i = 0; i < midCount; i++) {
let xv: number
let yv: number
try {
const vx = await ElMessageBox.prompt(
`${i + 1}/${midCount} 个控制点 X (mm)`,
`添加${label}`,
{ inputValue: String(((sa.x + sb.x) / 2).toFixed(0)) }
)
xv = Number(vx.value)
const vy = await ElMessageBox.prompt(
`${i + 1}/${midCount} 个控制点 Y (mm)`,
`添加${label}`,
{ inputValue: String(((sa.y + sb.y) / 2).toFixed(0)) }
)
yv = Number(vy.value)
} catch { return 'cancelled' }
if (!Number.isFinite(xv) || !Number.isFinite(yv)) {
ElMessage.warning('控制点坐标必须是数字')
return 'cancelled'
}
midPts.push({ x: xv, y: yv })
}
const allPts: XYPoint[] = [sa, ...midPts, sb]
return {
typeInfo: buildCurveTypeInfo(allPts, kind === 'bezier' ? 2 : 3),
midCount
}
}
/**
* 「按 ID 添加」场景下收集弧线半径并拼出 typeInfo。
* 画布绘制弧线时收集半径并拼出 typeInfo。
*
* 半径校验:必须 ≥ |AB|/2(小于则圆心不存在或重合,无法形成弧)。默认值用 ceil(|AB|/2) 即半圆。
*/
@@ -705,30 +600,6 @@ async function promptCreateLayer() {
} catch { /* user cancelled */ }
}
async function promptAddDummyCarByCoord() {
try {
const x = Number((await ElMessageBox.prompt('X 坐标 (mm)', '添加模拟车', { inputValue: '0' })).value)
const y = Number((await ElMessageBox.prompt('Y 坐标 (mm)', '添加模拟车', { inputValue: '0' })).value)
const theta = Number((await ElMessageBox.prompt('朝向 theta (rad0 = +X 向右)', '添加模拟车', { inputValue: '0' })).value)
const name = (await ElMessageBox.prompt('车名(可空)', '添加模拟车', { inputValue: '' })).value
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(theta)) {
ElMessage.warning('坐标 / 朝向必须是数字')
return
}
let createdId = -1
await history.run({
label: `新建模拟车 (${x.toFixed(0)}, ${y.toFixed(0)})`,
apply: async () => {
const r = await mapEditApi.createCar({ x, y, theta, name })
createdId = r.id
},
revert: async () => {
if (createdId > 0) await mapEditApi.deleteObject('car', createdId)
}
})
} catch { /* user cancelled */ }
}
// ──────────────────────────────────────────────────────────────────────────
// 工具栏交互
// ──────────────────────────────────────────────────────────────────────────
@@ -1350,7 +1221,83 @@ async function onExecAction(method: string) {
function onSaveDefaults() {
localStorage.setItem('mapEditor.defaults', JSON.stringify(defaults))
ElMessage.success('类型默认已保存到本地')
ElMessage.success('新建默认已保存(下次创建对象时生效)')
}
function parseViewportPayload(raw: Record<string, unknown>): ViewportStylePayload {
const siteRaw = (raw.site ?? raw.Site) as Record<string, unknown> | undefined
const trackRaw = (raw.track ?? raw.Track) as Record<string, unknown> | undefined
const num = (o: Record<string, unknown> | undefined, camel: string, pascal: string) =>
Number(o?.[camel] ?? o?.[pascal] ?? 0)
const bool = (o: Record<string, unknown> | undefined, camel: string, pascal: string) =>
Boolean(o?.[camel] ?? o?.[pascal])
return normalizeViewportPayload({
site: {
drawScale: num(siteRaw, 'drawScale', 'DrawScale'),
dotRadiusNormal: num(siteRaw, 'dotRadiusNormal', 'DotRadiusNormal'),
dotRadiusSelected: num(siteRaw, 'dotRadiusSelected', 'DotRadiusSelected'),
selectionRingM: num(siteRaw, 'selectionRingM', 'SelectionRingM'),
colorNormalArgb: num(siteRaw, 'colorNormalArgb', 'ColorNormalArgb'),
colorSelectedArgb: num(siteRaw, 'colorSelectedArgb', 'ColorSelectedArgb'),
labelColorNormalArgb: num(siteRaw, 'labelColorNormalArgb', 'LabelColorNormalArgb'),
labelColorSelectedArgb: num(siteRaw, 'labelColorSelectedArgb', 'LabelColorSelectedArgb')
},
track: {
drawScale: num(trackRaw, 'drawScale', 'DrawScale'),
widthNormal: num(trackRaw, 'widthNormal', 'WidthNormal'),
widthSelected: num(trackRaw, 'widthSelected', 'WidthSelected'),
showDirectionArrows: bool(trackRaw, 'showDirectionArrows', 'ShowDirectionArrows'),
colorNormalArgb: num(trackRaw, 'colorNormalArgb', 'ColorNormalArgb'),
colorSelectedArgb: num(trackRaw, 'colorSelectedArgb', 'ColorSelectedArgb')
}
})
}
async function loadViewportStyle() {
viewportStyleLoading.value = true
try {
const raw = await reflectionApi.getViewportStyle()
viewportStyle.value = parseViewportPayload(raw as unknown as Record<string, unknown>)
} catch (err) {
console.warn('[MapEditor] 加载视口样式失败:', err)
} finally {
viewportStyleLoading.value = false
}
}
function onPatchViewport(patch: { site?: ViewportStyleSite; track?: ViewportStyleTrack }) {
if (patch.site) {
pendingViewportPatch.site = patch.site
if (viewportStyle.value) viewportStyle.value = { ...viewportStyle.value, site: { ...patch.site } }
}
if (patch.track) {
pendingViewportPatch.track = patch.track
if (viewportStyle.value) viewportStyle.value = { ...viewportStyle.value, track: { ...patch.track } }
}
if (viewportPatchTimer) clearTimeout(viewportPatchTimer)
viewportPatchTimer = setTimeout(() => {
const body = { ...pendingViewportPatch }
pendingViewportPatch = {}
void flushViewportPatch(body)
}, 350)
}
async function flushViewportPatch(patch: { site?: ViewportStyleSite; track?: ViewportStyleTrack }) {
const seq = ++viewportPatchSeq
viewportSyncing.value = true
try {
const body: { site?: ViewportStyleSite; track?: ViewportStyleTrack } = {}
if (patch.site) body.site = patch.site
if (patch.track) body.track = patch.track
const raw = await reflectionApi.patchViewportStyle(body)
if (seq !== viewportPatchSeq) return
viewportStyle.value = parseViewportPayload(raw as unknown as Record<string, unknown>)
} catch (err) {
ElMessage.error(`视口样式更新失败:${(err as Error).message}`)
await loadViewportStyle()
} finally {
if (seq === viewportPatchSeq) viewportSyncing.value = false
}
}
function onLayerToggle(_row: LayerRow) {
@@ -1515,11 +1462,13 @@ useCanvasBridge({
})
const stream = useMapEditStream({
onWorkspaceShortcut: (e) => { void handleEditorShortcut(e.action) },
onAlarm: () => { /* 编辑器不展示报警,由 Monitor 页订阅 */ },
onObjectCreated: () => refreshAfterMutation(),
onObjectDeleted: () => refreshAfterMutation(),
onObjectPatched: () => refreshAfterMutation(),
onObjectBatchChanged: () => refreshAfterMutation(),
onViewportStyleUpdated: () => { void loadViewportStyle() },
onSelectionDetail: (e) => {
// 把 SimpleLite 端 canvas 内的选中实时同步到 Vue 端,让右侧属性面板自动刷新。
//
@@ -1559,17 +1508,49 @@ watch(() => makeSelectionKey(selection.items.value), (key) => {
})
// ──────────────────────────────────────────────────────────────────────────
// 键盘快捷键
// 键盘快捷键(顶栏/侧栏聚焦时走 document;画布 iframe 聚焦时走 SSE workspace-shortcut
// ──────────────────────────────────────────────────────────────────────────
function isEditableShortcutTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
const tag = target.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
if (target.isContentEditable) return true
return !!target.closest('.el-input, .el-textarea, .el-select, [contenteditable="true"]')
}
async function handleEditorShortcut(action: 'undo' | 'redo' | 'delete') {
if (action === 'undo') {
if (!history.canUndo.value) return
await history.undo()
} else if (action === 'redo') {
if (!history.canRedo.value) return
await history.redo()
} else {
await deleteSelection()
}
}
function onKeyDown(ev: KeyboardEvent) {
if (isEditableShortcutTarget(ev.target)) return
const meta = ev.ctrlKey || ev.metaKey
if (meta && ev.key.toLowerCase() === 'z' && !ev.shiftKey) {
ev.preventDefault(); history.undo()
ev.preventDefault()
void handleEditorShortcut('undo')
} else if (meta && (ev.key.toLowerCase() === 'y' || (ev.key.toLowerCase() === 'z' && ev.shiftKey))) {
ev.preventDefault(); history.redo()
ev.preventDefault()
void handleEditorShortcut('redo')
} else if (ev.key === 'Delete') {
deleteSelection()
ev.preventDefault()
void handleEditorShortcut('delete')
}
}
function onWorkspaceShortcutEvent(ev: Event) {
const action = (ev as CustomEvent<{ action?: string }>).detail?.action
if (action === 'undo' || action === 'redo' || action === 'delete') {
void handleEditorShortcut(action)
}
}
@@ -1592,7 +1573,8 @@ async function syncViewFilterFromBackend() {
}
onMounted(async () => {
window.addEventListener('keydown', onKeyDown)
document.addEventListener('keydown', onKeyDown, true)
window.addEventListener('workspace-shortcut', onWorkspaceShortcutEvent as EventListener)
try {
const cfg = await aiConfigApi.get()
aiConfigured.value = !!cfg.configured
@@ -1603,10 +1585,12 @@ onMounted(async () => {
}
loadViewFilter()
await syncViewFilterFromBackend()
await loadViewportStyle()
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('workspace-shortcut', onWorkspaceShortcutEvent as EventListener)
})
</script>