Commit 9c9efc33 by James Yuan

feat: 场景 Marker 附近视频弹窗与坐标换算

点击摄像头 Marker 在附近打开可拖动预览窗,换算推流坐标并避免关闭穿透与标题显示为 id。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 0903597b
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
open: boolean
title: string
/** 对话框左上角(视口坐标);无效时由父级传屏幕中心 */
left: number
top: number
src?: string
}>(),
{
src: '/cams/lab-seq.mp4',
},
)
const emit = defineEmits<{ close: [] }>()
const BOX_W = 400
const BOX_H = 240
const TITLE_H = 36
const posLeft = ref(0)
const posTop = ref(0)
const dragging = ref(false)
let dragOx = 0
let dragOy = 0
function clampPos(left: number, top: number) {
const vw = window.innerWidth
const vh = window.innerHeight
return {
left: Math.max(0, Math.min(Math.round(left), Math.max(0, vw - BOX_W))),
top: Math.max(0, Math.min(Math.round(top), Math.max(0, vh - BOX_H))),
}
}
function syncFromProps() {
const c = clampPos(props.left, props.top)
posLeft.value = c.left
posTop.value = c.top
}
watch(
() => [props.open, props.left, props.top] as const,
([open]) => {
if (open) syncFromProps()
},
{ immediate: true },
)
const style = computed(() => ({
left: `${posLeft.value}px`,
top: `${posTop.value}px`,
width: `${BOX_W}px`,
height: `${BOX_H}px`,
}))
const videoH = BOX_H - TITLE_H
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') requestClose()
}
function onTitleDown(e: PointerEvent) {
if (e.button !== 0) return
// 关闭钮不进入拖拽,避免 pointer capture 吞掉 click
if ((e.target as HTMLElement | null)?.closest?.('.near-close')) return
const t = e.currentTarget as HTMLElement
t.setPointerCapture(e.pointerId)
dragging.value = true
dragOx = e.clientX - posLeft.value
dragOy = e.clientY - posTop.value
}
function onTitleMove(e: PointerEvent) {
if (!dragging.value) return
const c = clampPos(e.clientX - dragOx, e.clientY - dragOy)
posLeft.value = c.left
posTop.value = c.top
}
function onTitleUp(e: PointerEvent) {
if (!dragging.value) return
dragging.value = false
try {
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
} catch {
/* ignore */
}
}
function requestClose(e?: Event) {
e?.preventDefault()
e?.stopPropagation()
if (dragging.value) {
dragging.value = false
}
emit('close')
}
watch(
() => props.open,
(v) => {
if (v) window.addEventListener('keydown', onKey)
else window.removeEventListener('keydown', onKey)
},
)
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
</script>
<template>
<Teleport to="body">
<div v-if="open" class="near-layer">
<!-- pointerdown 关闭,避免 click 卸层后穿透到 iframe 再次打开 -->
<div
class="near-backdrop"
@pointerdown="requestClose"
@click.prevent.stop
/>
<div
class="near-dialog"
:style="style"
role="dialog"
:aria-label="title"
@pointerdown.stop
@click.stop
>
<header
class="near-head"
@pointerdown="onTitleDown"
@pointermove="onTitleMove"
@pointerup="onTitleUp"
@pointercancel="onTitleUp"
>
<h3 :title="title">{{ title }}</h3>
<button
class="near-close"
type="button"
aria-label="关闭"
@pointerdown.stop="requestClose"
@click.stop.prevent="requestClose"
>
×
</button>
</header>
<div class="near-body" :style="{ height: `${videoH}px` }">
<video
class="near-video"
:src="src"
autoplay
muted
loop
playsinline
controls
/>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.near-layer {
position: fixed;
inset: 0;
z-index: 340;
pointer-events: none;
}
.near-backdrop {
position: absolute;
inset: 0;
pointer-events: auto;
background: transparent;
}
.near-dialog {
position: absolute;
pointer-events: auto;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 6px;
border: 1px solid rgba(0, 247, 255, 0.4);
background: #071426;
box-shadow:
0 12px 36px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(0, 0, 0, 0.35);
}
.near-head {
flex: 0 0 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px 0 12px;
border-bottom: 1px solid rgba(0, 247, 255, 0.18);
background: linear-gradient(180deg, rgba(12, 42, 72, 0.98), rgba(6, 22, 42, 0.98));
cursor: move;
user-select: none;
touch-action: none;
}
.near-head h3 {
margin: 0;
min-width: 0;
flex: 1;
font-size: 13px;
font-weight: 600;
color: #e8fbff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.near-close {
flex: 0 0 auto;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: #9ec8dc;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.near-close:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
}
.near-body {
flex: 1;
min-height: 0;
background: #000;
}
.near-video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
background: #000;
}
</style>
......@@ -11,24 +11,27 @@ const emit = defineEmits<{ close: [] }>()
</script>
<template>
<div v-if="open" class="mask" @click.self="emit('close')">
<div class="dialog">
<div class="head">
<h3>{{ title || '视频预览' }}</h3>
<button class="btn ghost" type="button" @click="emit('close')">关闭</button>
</div>
<div class="body">
<WebRtcPlayer v-if="deviceId" :device-id="deviceId" />
<Teleport to="body">
<div v-if="open" class="mask" @click.self="emit('close')">
<div class="dialog" @click.stop>
<div class="head">
<h3>{{ title || '视频预览' }}</h3>
<button class="btn ghost" type="button" @click="emit('close')">关闭</button>
</div>
<div class="body">
<WebRtcPlayer v-if="deviceId" :device-id="deviceId" />
<div v-else class="empty">未绑定视频设备</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
z-index: 70;
z-index: 330;
background: rgba(0, 8, 20, 0.65);
display: grid;
place-items: center;
......@@ -60,4 +63,23 @@ const emit = defineEmits<{ close: [] }>()
min-height: 360px;
background: #000;
}
.empty {
min-height: 320px;
display: grid;
place-items: center;
color: rgba(185, 214, 240, 0.55);
font-size: 13px;
}
.btn {
border: 1px solid rgba(0, 247, 255, 0.28);
background: transparent;
color: #d7f7ff;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 12px;
}
.btn:hover {
border-color: rgba(0, 247, 255, 0.55);
}
</style>
......@@ -143,8 +143,8 @@ export function parseScenePointFromMessage(data: unknown): ScenePoint | null {
return null
}
export function parseMarkerIdFromMessage(data: unknown): string | null {
if (!data || typeof data !== 'object') return null
function messageBags(data: unknown): Record<string, unknown>[] {
if (!data || typeof data !== 'object') return []
const root = data as Record<string, unknown>
const bags: Record<string, unknown>[] = [root]
if (root.data && typeof root.data === 'object') bags.push(root.data as Record<string, unknown>)
......@@ -158,11 +158,69 @@ export function parseMarkerIdFromMessage(data: unknown): string | null {
/* ignore */
}
}
for (const o of bags) {
const id = o.id ?? o.markerId ?? o.objectId ?? o.tag0 ?? o.tag1
if (id != null && String(id).trim()) return String(id).trim()
if (root.params && typeof root.params === 'object') {
bags.push(root.params as Record<string, unknown>)
}
return null
return bags
}
export type SceneMarkerHit = {
id: string | null
name: string | null
type: string | null
tag0: string | null
tag1: string | null
tag2: string | null
icon: string | null
eventId: string | null
}
export function parseMarkerInfoFromMessage(data: unknown): SceneMarkerHit {
const empty: SceneMarkerHit = {
id: null,
name: null,
type: null,
tag0: null,
tag1: null,
tag2: null,
icon: null,
eventId: null,
}
const bags = messageBags(data)
if (!bags.length) return empty
const pick = (...keys: string[]) => {
for (const o of bags) {
for (const k of keys) {
const v = o[k]
if (v != null && String(v).trim()) return String(v).trim()
}
}
return null
}
const id = pick('id', 'markerId', 'objectId')
// 优先可读文案;若 name 与 id 相同(引擎常把 id 填进 name),改用 text/title/label
const rawName = pick('name', 'text', 'title', 'label')
const altLabel = pick('text', 'title', 'label', 'name')
const name =
rawName && id && rawName === id && altLabel && altLabel !== id ? altLabel : rawName
return {
id,
name,
type: pick('type', 'objectType'),
tag0: pick('tag0', 'Tag0'),
tag1: pick('tag1', 'Tag1'),
tag2: pick('tag2', 'Tag2'),
icon: pick('icon', 'url', 'Icon'),
eventId: pick('eventId'),
}
}
export function parseMarkerIdFromMessage(data: unknown): string | null {
const info = parseMarkerInfoFromMessage(data)
return info.id || info.tag1 || info.tag0 || null
}
type GisObj = {
......@@ -177,7 +235,12 @@ type GisObj = {
export function useRtengineBridge(opts: {
iframeRef: Ref<HTMLIFrameElement | null>
cloudUrl: string
onMouseClick?: (payload: { point: ScenePoint | null; markerId: string | null; raw: unknown }) => void
onMouseClick?: (payload: {
point: ScenePoint | null
markerId: string | null
marker: SceneMarkerHit
raw: unknown
}) => void
onReady?: () => void
}) {
const ready = ref(false)
......@@ -420,9 +483,11 @@ export function useRtengineBridge(opts: {
type === 'selectMarker' ||
(data && typeof data === 'object' && (data as { eventId?: string }).eventId === 'mouseClick')
) {
const marker = parseMarkerInfoFromMessage(data)
opts.onMouseClick?.({
point: parseScenePointFromMessage(data),
markerId: parseMarkerIdFromMessage(data),
markerId: marker.id || marker.tag1 || marker.tag0,
marker,
raw: data,
})
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment