Commit 2b579ac5 by James Yuan

feat: 大屏底栏七态 POI 经云渲染 addMarker 切换

底栏改为主页/压差/N·E电照明/冰箱/通风柜/E栋;原区域飞视改右侧并去掉功能侧栏;实时文案写入 RTEngine Marker,不叠 HTML 标签。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent a1ad7540
import { evaluatePointAlarms } from '../devices/alarms.js'
import { listDevices, listGateways, listPoints } from '../devices/store.js'
import type { Device, DevicePoint } from '../devices/types.js'
import type { FridgeSnapshot, RealtimeSnapshot, RoomSnapshot, VavSnapshot } from '../types.js'
import type {
FridgeSnapshot,
NeLightSnapshot,
RealtimeSnapshot,
RoomSnapshot,
VavSnapshot,
} from '../types.js'
import { NE_LIGHT_DEFS } from './neLights.js'
/** 与前端 PROTOCOL_ROOMS / 场景书签对齐的稳定房间 id */
const ROOM_ID_BY_NAME: Record<string, string> = {
......@@ -9,6 +16,7 @@ const ROOM_ID_BY_NAME: Record<string, string> = {
生信分析报告室: 'report',
试剂准备间: 'reagent',
PCR走廊: 'pcr-corridor',
NGS走廊: 'pcr-corridor',
样本制备区1: 'sample-1',
样本制备区2: 'sample-2',
样本制备区缓冲: 'sample-buf',
......@@ -149,6 +157,18 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> {
.filter((d) => d.category === 'vav')
.map((d) => vavFromDevice(d, byDevice.get(d.id) || []))
const neLights: NeLightSnapshot[] = NE_LIGHT_DEFS.map((def) => {
const pts = byDevice.get(def.deviceId) || []
const p = pts.find((x) => x.name === def.pointName || x.itemId.endsWith(`.${def.pointName}`))
const v = pointFresh(p) ? num(p?.lastEng) ?? num(p?.lastRaw) : null
return {
id: def.id,
kind: def.kind,
zone: def.zone,
on: v !== null && v !== 0,
}
})
const pauDevs = enabled.filter((d) => d.category === 'pau')
const pauPts = pauDevs.flatMap((d) => byDevice.get(d.id) || [])
const systemRunning = boolOn(pauPts, /系统启动信号|^开机$|PAU201送风机启停/)
......@@ -199,6 +219,7 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> {
},
vav,
fridges,
neLights,
metering: {
powerKwh: sumEng(powerMeters, byDevice, /用电量|有功电能|总有功/),
waterM3: sumEng(waterMeters, byDevice, /用水量|累计流量|流量/),
......
/**
* 房间 N电 / E电 回路 → 区域映射(协议 CSV 末列注释)。
* 普通照明 = N电(QTN*);应急照明 = E电(QTE*)。
*/
export type NeLightKind = 'N' | 'E'
export type NeLightDef = {
id: string
kind: NeLightKind
zone: string
deviceId: string
/** 点位 name,如 KA1 */
pointName: string
}
export const NE_LIGHT_DEFS: NeLightDef[] = [
// N电
{ id: 'n-seq', kind: 'N', zone: '测序区', deviceId: 'mb-104-qtn1', pointName: 'KA1' },
{ id: 'n-lib-2', kind: 'N', zone: '文库制备区2', deviceId: 'mb-104-qtn2', pointName: 'KA1' },
{ id: 'n-lib-1', kind: 'N', zone: '文库制备区1', deviceId: 'mb-104-qtn3', pointName: 'KA1' },
{ id: 'n-sample-2', kind: 'N', zone: '样本制备区2', deviceId: 'mb-104-qtn4', pointName: 'KA1' },
{ id: 'n-sample-1', kind: 'N', zone: '样本制备区1', deviceId: 'mb-104-qtn4', pointName: 'KA3' },
{ id: 'n-report', kind: 'N', zone: '报告室', deviceId: 'mb-104-qtn5', pointName: 'KA1' },
{ id: 'n-corridor', kind: 'N', zone: 'NGS走廊', deviceId: 'mb-104-qtn6', pointName: 'KA1' },
// E电
{ id: 'e-seq-buf', kind: 'E', zone: '测序区缓冲', deviceId: 'mb-104-qte1', pointName: 'KA1' },
{ id: 'e-seq', kind: 'E', zone: '测序区', deviceId: 'mb-104-qte1', pointName: 'KA2' },
{ id: 'e-lib-2-buf', kind: 'E', zone: '文库制备区2缓冲', deviceId: 'mb-104-qte2', pointName: 'KA1' },
{ id: 'e-lib-2', kind: 'E', zone: '文库制备区2', deviceId: 'mb-104-qte2', pointName: 'KA2' },
{ id: 'e-lib-1-buf', kind: 'E', zone: '文库制备区1缓冲', deviceId: 'mb-104-qte3', pointName: 'KA1' },
{ id: 'e-lib-1', kind: 'E', zone: '文库制备区1', deviceId: 'mb-104-qte3', pointName: 'KA2' },
{ id: 'e-sample-1-buf', kind: 'E', zone: '样本制备区1缓冲', deviceId: 'mb-104-qte4', pointName: 'KA1' },
{ id: 'e-sample-2', kind: 'E', zone: '样本制备区2', deviceId: 'mb-104-qte4', pointName: 'KA2' },
{ id: 'e-sample-1', kind: 'E', zone: '样本制备区1', deviceId: 'mb-104-qte4', pointName: 'KA3' },
{ id: 'e-report', kind: 'E', zone: '报告室', deviceId: 'mb-104-qte5', pointName: 'KA1' },
{ id: 'e-prep', kind: 'E', zone: '准备间', deviceId: 'mb-104-qte5', pointName: 'KA2' },
{ id: 'e-recv', kind: 'E', zone: '咨询室', deviceId: 'mb-104-qte5', pointName: 'KA3' },
{ id: 'e-corridor', kind: 'E', zone: '走廊', deviceId: 'mb-104-qte6', pointName: 'KA1' },
]
......@@ -30,6 +30,7 @@ let snapshot: RealtimeSnapshot = {
},
vav: [],
fridges: [],
neLights: [],
metering: {
powerKwh: 0,
waterM3: 0,
......
......@@ -51,6 +51,14 @@ export interface FridgeSnapshot {
humidity: number
}
/** 普通照明(N电) / 应急照明(E电) 回路状态 */
export interface NeLightSnapshot {
id: string
kind: 'N' | 'E'
zone: string
on: boolean
}
export interface DoorSnapshot {
id: string
name: string
......@@ -97,6 +105,8 @@ export interface RealtimeSnapshot {
}
vav: VavSnapshot[]
fridges: FridgeSnapshot[]
/** N电/E电照明回路(大屏普通/应急照明态) */
neLights?: NeLightSnapshot[]
metering: {
powerKwh: number
waterM3: number
......
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'
import { useRealtimeStore } from '@/stores/realtime'
import { useScreenModeStore } from '@/stores/screenMode'
import { SCREEN_AREAS } from '@/data/rooms'
import { SCREEN_MODES, fmtNum, type ScreenMode } from '@/data/screenModePois'
import AppIcon from '@/components/icons/AppIcon.vue'
import MarkerNearDialog from '@/components/scene/MarkerNearDialog.vue'
import { api } from '@/api/http'
......@@ -11,6 +14,7 @@ import {
type SceneMarkerHit,
type ScenePoint,
} from '@/composables/useRtengineBridge'
import { useScreenModePois } from '@/composables/useScreenModePois'
import type { VideoDevice } from '@/types/video'
const props = withDefaults(
......@@ -23,6 +27,8 @@ const props = withDefaults(
)
const realtime = useRealtimeStore()
const screenMode = useScreenModeStore()
const { mode: activeMode } = storeToRefs(screenMode)
type ViewportMode = 'glb' | 'ue-stream' | 'rtengine'
const mode = (import.meta.env.VITE_VIEWPORT_MODE || 'rtengine') as ViewportMode
......@@ -400,10 +406,14 @@ const bridge = useRtengineBridge({
onReady: () => {
void onEngineReady()
bindIframePointer()
syncModePois()
},
onMouseClick: onMarkerClick,
})
const modePoisEnabled = computed(() => props.layout === 'fill' && useCloudFrame.value)
const { syncModePois } = useScreenModePois(bridge, modePoisEnabled, cloudUrl)
function cameraSceneSig(list: VideoDevice[]) {
return list
.filter((d) => d.enabled && d.scenePoint)
......@@ -517,19 +527,15 @@ const selected = computed(() => {
)
})
const markers = computed(() => {
if (useCloudFrame.value) return []
return areaList.value
.filter((r) => ['sample-1', 'lib-1', 'seq'].includes(r.id))
.map((r, i) => ({
id: r.id,
label: `${r.name} ${r.pressure != null ? `${r.pressure.toFixed(1)}Pa` : '--'}`,
err: r.alarms.length > 0,
style: {
left: `${18 + i * 26}%`,
top: `${42 + (i % 2) * 12}%`,
},
}))
const e066Fridges = computed(() => {
const list = realtime.snapshot.fridges
if (list.length) return list.slice(0, 8)
return Array.from({ length: 8 }, (_, i) => ({
id: `ph-${i}`,
name: `冰箱${i + 1}`,
temp: 0,
humidity: 0,
}))
})
const modeLabel = computed(() => {
......@@ -552,6 +558,10 @@ function onSelectArea(areaId: string) {
if (useCloudFrame.value) flyToViewpoint(area.viewpoint)
}
function onSelectMode(next: ScreenMode) {
screenMode.setMode(next)
}
function onSelect(id: string) {
// 兼容 marker:id 可能是 areaId 或 roomId
const byArea = areaList.value.find((r) => r.id === id)
......@@ -601,21 +611,46 @@ onBeforeUnmount(() => {
<span class="view">{{ modeLabel }}</span>
</div>
<!-- 全屏:Teleport 到 float 层(高于 edge-mask);嵌入:右侧纵向 -->
<!-- 全屏:底栏七态 + 右侧区域飞视;嵌入:右侧纵向区域 -->
<Teleport v-if="props.layout === 'fill' && floatTargetReady" to="#screen-float-overlays" defer>
<div class="vp-room-panel vp-room-panel--fill">
<div class="vp-room-list">
<button
v-for="r in areaList"
:key="r.id"
type="button"
class="vp-room-item"
:class="{ active: r.id === activeAreaId, alarm: r.alarms.length > 0 }"
@click="onSelectArea(r.id)"
>
<AppIcon name="pin" class-name="room-ico" />
<span class="name">{{ r.name }}</span>
</button>
<!-- 右侧:原底部区域位置切换 -->
<div class="vp-area-side" aria-label="区域飞视">
<button
v-for="r in areaList"
:key="r.id"
type="button"
class="vp-area-side-item"
:class="{ active: r.id === activeAreaId, alarm: r.alarms.length > 0 }"
@click="onSelectArea(r.id)"
>
<AppIcon name="pin" class-name="room-ico" />
<span class="name">{{ r.name }}</span>
</button>
</div>
<!-- 底栏:状态模式(不飞相机) -->
<div class="vp-mode-bar" aria-label="状态模式">
<button
v-for="item in SCREEN_MODES"
:key="item.id"
type="button"
class="vp-mode-item"
:class="{ active: item.id === activeMode }"
@click="onSelectMode(item.id)"
>
{{ item.label }}
</button>
</div>
<!-- E栋1F066:八格冰箱卡片 -->
<div v-if="activeMode === 'e066'" class="vp-e066" aria-label="E栋冰箱">
<div class="vp-e066-title">E栋冰箱</div>
<div class="vp-e066-grid">
<div v-for="(f, i) in e066Fridges" :key="f.id" class="vp-e066-card">
<div class="vp-e066-name">冰箱{{ i + 1 }}</div>
<div class="vp-e066-row temp">温度 {{ fmtNum(f.temp) }} ℃</div>
<div class="vp-e066-row hum">湿度 {{ fmtNum(f.humidity, 0) }} %</div>
</div>
</div>
</div>
</Teleport>
......@@ -666,17 +701,6 @@ onBeforeUnmount(() => {
@contextmenu.prevent
/>
<div
v-for="m in markers"
:key="m.id"
class="mk"
:class="{ err: m.err }"
:style="m.style"
@click="onSelect(m.id)"
>
<span class="dot" :class="{ red: m.err }" />
<span>{{ m.label }}</span>
</div>
<div v-if="props.layout !== 'fill'" class="vp-tip">
<AppIcon name="hand" class-name="hand" />
......@@ -747,28 +771,147 @@ onBeforeUnmount(() => {
overflow: hidden;
pointer-events: auto;
}
/* 全屏:夹在左右面板之间,贴地图底部横向;无外层背景面板 */
.vp-room-panel--fill {
/* 右侧区域飞视(原底栏位置切换) */
.vp-area-side {
position: absolute;
z-index: 8;
/* 贴在右侧业务面板内侧,对应原 screen-nav--side 占位 */
right: calc(var(--screen-side-w, 24vw) + 16px);
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 6px;
max-height: min(72vh, 640px);
overflow-y: auto;
overflow-x: hidden;
padding: 4px;
pointer-events: auto;
}
.vp-area-side-item {
display: flex;
align-items: center;
gap: 6px;
min-width: 108px;
padding: 8px 12px;
border-radius: 4px;
border: 1px solid rgba(91, 212, 255, 0.32);
background: rgba(8, 28, 58, 0.62);
backdrop-filter: blur(6px);
color: rgba(235, 245, 255, 0.95);
cursor: pointer;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.vp-area-side-item :deep(.room-ico) {
width: 12px;
height: 12px;
opacity: 0.9;
}
.vp-area-side-item:hover {
border-color: rgba(62, 200, 255, 0.5);
background: rgba(30, 82, 150, 0.7);
}
.vp-area-side-item.active {
border-color: rgba(91, 212, 255, 0.7);
background: rgba(26, 111, 255, 0.55);
box-shadow: inset 0 0 0 1px rgba(91, 212, 255, 0.15);
}
.vp-area-side-item.alarm .name {
color: #ffb0b0;
}
/* 底栏七态(参考 McgsPro) */
.vp-mode-bar {
position: absolute;
z-index: 8;
left: 50%;
right: auto;
top: auto;
bottom: clamp(14px, 2.2vh, 28px);
width: max-content;
max-width: min(960px, calc(100vw - 2 * var(--screen-side-w, 24vw) - 48px));
max-height: none;
bottom: clamp(12px, 2vh, 24px);
transform: translateX(-50%);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
border: none;
border-radius: 0;
background: transparent;
backdrop-filter: none;
box-shadow: none;
overflow: visible;
z-index: 8;
gap: 8px;
max-width: min(1100px, calc(100vw - 160px));
overflow-x: auto;
pointer-events: auto;
}
.vp-mode-item {
flex: 0 0 auto;
min-width: 88px;
padding: 10px 14px;
border-radius: 4px;
border: 1px solid rgba(91, 212, 255, 0.28);
background: rgba(8, 28, 58, 0.72);
backdrop-filter: blur(6px);
color: rgba(230, 240, 255, 0.92);
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.vp-mode-item:hover {
border-color: rgba(91, 212, 255, 0.5);
}
.vp-mode-item.active {
color: #3dff9a;
border-color: rgba(61, 255, 154, 0.55);
background: rgba(10, 48, 40, 0.75);
box-shadow: 0 0 10px rgba(61, 255, 154, 0.18);
}
/* E栋1F066 八格卡片 */
.vp-e066 {
position: absolute;
z-index: 9;
left: 50%;
top: 52%;
transform: translate(-50%, -50%);
width: min(720px, 70vw);
padding: 16px 18px 20px;
border-radius: 8px;
border: 1px solid rgba(91, 212, 255, 0.35);
background: rgba(6, 18, 42, 0.88);
backdrop-filter: blur(10px);
pointer-events: auto;
}
.vp-e066-title {
text-align: center;
font-size: 20px;
font-weight: 700;
color: #fff;
margin-bottom: 14px;
letter-spacing: 0.08em;
}
.vp-e066-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.vp-e066-card {
border: 1px solid rgba(80, 230, 255, 0.55);
border-radius: 4px;
padding: 10px 12px;
background: rgba(4, 28, 56, 0.65);
box-shadow: 0 0 12px rgba(60, 200, 255, 0.12);
}
.vp-e066-name {
font-size: 13px;
color: rgba(220, 240, 255, 0.9);
margin-bottom: 6px;
}
.vp-e066-row {
font-size: 14px;
font-weight: 600;
line-height: 1.45;
}
.vp-e066-row.temp {
color: #5ce8ff;
}
.vp-e066-row.hum {
color: #ff7ad9;
}
.vp-room-head {
display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 7px 8px;
......
......@@ -415,6 +415,14 @@ export function useRtengineBridge(opts: {
send('clearMarkers', {})
}
/** 更新已有 Marker 文案/坐标(引擎无 update 时降级为 remove + add) */
function updateMarker(m: RtengineMarker) {
const payload = markerPayload(m)
send('updateMarker', payload)
// 部分版本无 updateMarker:再写一次 add 覆盖
send('addMarker', payload)
}
let presetsShown = false
/** 显示场景内预设 POI / Marker(合并为一次,减少场景刷新/光照闪变) */
function showScenePresets() {
......@@ -500,6 +508,53 @@ export function useRtengineBridge(opts: {
window.addEventListener('message', onMessage)
onBeforeUnmount(() => window.removeEventListener('message', onMessage))
/** 从 iframe $gisObj 读取已注册书签坐标(供 addMarker 落点) */
function readBookmarkPositions(): Map<string, ScenePoint> {
const map = new Map<string, ScenePoint>()
const gis = gisObj()
if (!gis) return map
const rawLists: unknown[] = []
try {
if (typeof (gis as { getBookmarks?: () => unknown }).getBookmarks === 'function') {
rawLists.push((gis as { getBookmarks: () => unknown }).getBookmarks())
}
} catch {
/* ignore */
}
for (const key of ['bookmarks', '_bookmarks', 'bookmarkList', 'views']) {
const v = (gis as Record<string, unknown>)[key]
if (v) rawLists.push(v)
}
for (const raw of rawLists) {
const list = Array.isArray(raw)
? raw
: raw && typeof raw === 'object' && Array.isArray((raw as { bookmarks?: unknown[] }).bookmarks)
? (raw as { bookmarks: unknown[] }).bookmarks
: []
for (const item of list) {
if (!item || typeof item !== 'object') continue
const o = item as Record<string, unknown>
const name = String(o.name ?? o.id ?? o.title ?? '').trim()
if (!name) continue
const bags: Record<string, unknown>[] = [o]
for (const k of ['camera', 'position', 'pos', 'location', 'coord', 'coordinates', 'view']) {
const sub = o[k]
if (sub && typeof sub === 'object') bags.push(sub as Record<string, unknown>)
}
for (const bag of bags) {
const x = Number(bag.x ?? bag.lng ?? bag.longitude ?? bag.lon)
const y = Number(bag.y ?? bag.lat ?? bag.latitude)
const z = Number(bag.z ?? bag.height ?? bag.alt ?? bag.altitude ?? 0)
if (Number.isFinite(x) && Number.isFinite(y)) {
map.set(name, { x, y, z: Number.isFinite(z) ? z : 0 })
break
}
}
}
}
return map
}
return {
ready,
send,
......@@ -509,8 +564,11 @@ export function useRtengineBridge(opts: {
addMarkers,
removeMarker,
clearMarkers,
updateMarker,
showScenePresets,
markReady,
flushPendingFlies,
readBookmarkPositions,
ensureBookmarks,
}
}
import { watch, type Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRealtimeStore } from '@/stores/realtime'
import { useScreenModeStore } from '@/stores/screenMode'
import { fmtNum, poisForMode, type ModePoiDef } from '@/data/screenModePois'
import type { RtengineMarker, ScenePoint } from '@/composables/useRtengineBridge'
import type { RealtimeSnapshot } from '@/types'
type BridgeLike = {
ready: Ref<boolean>
addMarker: (m: RtengineMarker) => void
addMarkers: (list: RtengineMarker[]) => void
removeMarker: (id: string) => void
updateMarker: (m: RtengineMarker) => void
readBookmarkPositions?: () => Map<string, ScenePoint>
ensureBookmarks?: () => Promise<void>
}
const MODE_TAG = 'screen-mode'
const PREFIX = 'sm:'
/** 书签名别名:roomHint → 场景书签候选名 */
const BOOKMARK_ALIASES: Record<string, string[]> = {
测序区: ['测序区'],
测序区缓冲: ['测序区缓冲', '测序区'],
文库制备区1: ['文库制备区1'],
文库制备区1缓冲: ['文库制备区1缓冲', '文库制备区1'],
文库制备区2: ['文库制备区2'],
文库制备区2缓冲: ['文库制备区2缓冲', '文库制备区2'],
样本制备区1: ['样本制备区1'],
样本制备区2: ['样本制备区2'],
样本制备区缓冲: ['样本制备区缓冲', '样本制备区1'],
咨询室: ['咨询室', '收样咨询室'],
准备区: ['准备区', '试剂准备间'],
报告室: ['报告室', '生信分析报告室'],
PCR走廊: ['PCR走廊', 'NGS走廊'],
}
/** 相对父书签的微调(缓冲靠门侧) */
const HINT_OFFSET: Record<string, ScenePoint> = {
测序区缓冲: { x: 0, y: -0.00004, z: -0.5 },
文库制备区1缓冲: { x: 0, y: -0.00004, z: -0.5 },
文库制备区2缓冲: { x: 0, y: -0.00004, z: -0.5 },
样本制备区缓冲: { x: 0, y: -0.00004, z: -0.5 },
}
function poiMarkerId(def: ModePoiDef) {
return `${PREFIX}${def.id}`
}
function buildText(def: ModePoiDef, snap: RealtimeSnapshot): string {
const bind = def.bind
if (bind.kind === 'room') {
const r = snap.rooms.find((x) => x.id === bind.roomId)
return `温度:${fmtNum(r?.temp)}℃\n湿度:${fmtNum(r?.humidity, 0)}%\n压差:${fmtNum(r?.pressure)}Pa`
}
if (bind.kind === 'fridge') {
const f = snap.fridges.find((x) => x.id === bind.deviceId)
return `温度:${fmtNum(f?.temp)}℃\n湿度:${fmtNum(f?.humidity, 0)}%`
}
if (bind.kind === 'vav') {
const v = snap.vav.find((x) => x.id === bind.deviceId)
const run = v?.running ? '运行' : '停止'
return `状态:${run}\n面风速:${fmtNum(v?.faceVelocity)}m/s\n风量:${fmtNum(v?.airflow, 0)}`
}
if (bind.kind === 'neLight') {
const lit = (snap.neLights || []).find((x) => x.id === bind.lightId)
return `状态:${lit?.on ? '开' : '关'}`
}
return def.label
}
function pickPointFromUnknown(o: Record<string, unknown>): ScenePoint | null {
const x = Number(o.x ?? o.lng ?? o.longitude ?? o.lon ?? o.X)
const y = Number(o.y ?? o.lat ?? o.latitude ?? o.Y)
const z = Number(o.z ?? o.height ?? o.alt ?? o.altitude ?? o.Z ?? 0)
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
return { x, y, z: Number.isFinite(z) ? z : 0 }
}
function pointFromBookmark(raw: unknown): { name: string; point: ScenePoint } | null {
if (!raw || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
const name = String(o.name ?? o.id ?? o.title ?? o.label ?? '').trim()
if (!name) return null
const bags: Record<string, unknown>[] = [o]
for (const key of ['camera', 'position', 'pos', 'location', 'coord', 'coordinates', 'view', 'viewpoint']) {
const v = o[key]
if (v && typeof v === 'object') bags.push(v as Record<string, unknown>)
}
for (const bag of bags) {
const p = pickPointFromUnknown(bag)
if (p) return { name, point: p }
}
return null
}
/** 从 /designer 场景 JSON 拉取书签坐标,供 addMarker 使用 */
export async function fetchBookmarkPositions(cloudUrl: string): Promise<Map<string, ScenePoint>> {
const map = new Map<string, ScenePoint>()
let sceneId = ''
try {
sceneId = new URL(cloudUrl, window.location.href).searchParams.get('scene') || ''
} catch {
/* ignore */
}
if (!sceneId) return map
const urls = [
`/designer/data/scenes/${encodeURIComponent(sceneId)}.json`,
`/designer/api/embed/scenes/${encodeURIComponent(sceneId)}`,
]
for (const url of urls) {
try {
const res = await fetch(url)
if (!res.ok) continue
const json = (await res.json()) as Record<string, unknown>
const list =
(Array.isArray(json.bookmarks) && json.bookmarks) ||
(Array.isArray(json.views) && json.views) ||
(Array.isArray(json.cameras) && json.cameras) ||
[]
for (const item of list) {
const hit = pointFromBookmark(item)
if (hit) map.set(hit.name, hit.point)
}
if (map.size) return map
} catch {
/* try next */
}
}
return map
}
function resolveCoords(
def: ModePoiDef,
bookmarks: Map<string, ScenePoint>,
): ScenePoint {
// 配置表已标定优先
if (def.x || def.y || def.z) return { x: def.x, y: def.y, z: def.z }
const names = BOOKMARK_ALIASES[def.roomHint] || [def.roomHint, def.label]
for (const n of names) {
const base = bookmarks.get(n)
if (!base) continue
const off = HINT_OFFSET[def.roomHint]
if (off && n !== def.roomHint) {
return { x: base.x + off.x, y: base.y + off.y, z: base.z + off.z }
}
return { ...base }
}
// 无书签时仍返回配置值(可能为 0);由调用方决定是否写入
return { x: def.x, y: def.y, z: def.z }
}
function toMarker(
def: ModePoiDef,
snap: RealtimeSnapshot,
bookmarks: Map<string, ScenePoint>,
): RtengineMarker | null {
const p = resolveCoords(def, bookmarks)
if (!p.x && !p.y && !p.z) return null
return {
id: poiMarkerId(def),
name: def.label,
text: buildText(def, snap),
showText: true,
type: 'marker',
x: p.x,
y: p.y,
z: p.z,
tag0: MODE_TAG,
tag1: def.id,
scale: 1,
}
}
/**
* 按大屏七态通过 RTEngine addMarker 同步云渲染 POI(不飞相机;不在大屏 HTML 叠标签)。
*/
export function useScreenModePois(
bridge: BridgeLike,
enabled: Ref<boolean>,
cloudUrl: string,
) {
const modeStore = useScreenModeStore()
const realtime = useRealtimeStore()
const { mode } = storeToRefs(modeStore)
let activeIds: string[] = []
let lastSig = ''
let timer: number | undefined
let bookmarks = new Map<string, ScenePoint>()
let bookmarksLoaded = false
let loadingBookmarks = false
async function ensureBookmarkCoords() {
if (bookmarksLoaded || loadingBookmarks) return
loadingBookmarks = true
try {
await bridge.ensureBookmarks?.()
const fromGis = bridge.readBookmarkPositions?.() || new Map()
if (fromGis.size) {
bookmarks = fromGis
} else {
bookmarks = await fetchBookmarkPositions(cloudUrl)
}
bookmarksLoaded = bookmarks.size > 0
} finally {
loadingBookmarks = false
}
}
function clearModeMarkers() {
for (const id of activeIds) {
try {
bridge.removeMarker(id)
} catch {
/* ignore */
}
}
activeIds = []
lastSig = ''
}
function sync(force = false) {
if (!enabled.value || !bridge.ready.value) return
const snap = realtime.snapshot
const defs = poisForMode(mode.value)
const markers = defs
.map((d) => toMarker(d, snap, bookmarks))
.filter((m): m is RtengineMarker => !!m)
const sig = markers.map((m) => `${m.id}|${m.text}|${m.x},${m.y},${m.z}`).join(';')
if (!force && sig === lastSig) return
const nextIds = new Set(markers.map((m) => m.id))
for (const id of activeIds) {
if (!nextIds.has(id)) bridge.removeMarker(id)
}
// 切模式:批量 addMarkers;同模式刷新文案:逐个 updateMarker(内部会 addMarker)
if (!activeIds.length || force || defs.length !== activeIds.length) {
for (const id of activeIds) {
if (nextIds.has(id)) bridge.removeMarker(id)
}
if (markers.length) bridge.addMarkers(markers)
} else {
for (const m of markers) bridge.updateMarker(m)
}
activeIds = markers.map((m) => m.id)
lastSig = sig
}
function scheduleSync() {
window.clearTimeout(timer)
timer = window.setTimeout(() => sync(false), 400)
}
async function syncModePois() {
await ensureBookmarkCoords()
clearModeMarkers()
sync(true)
// 书签若稍晚才可用,再补一次
if (!bookmarksLoaded) {
window.setTimeout(() => {
void ensureBookmarkCoords().then(() => {
clearModeMarkers()
sync(true)
})
}, 2500)
}
}
watch(
[mode, enabled, () => bridge.ready.value],
() => {
void syncModePois()
},
{ flush: 'post' },
)
watch(
() => realtime.snapshot.ts,
() => scheduleSync(),
)
return {
clearModeMarkers,
syncModePois,
}
}
/**
* 大屏底栏七态 ↔ 设备/回路 ↔ 场景 POI。
* 坐标 (x=lng, y=lat, z=height) 初值为 0,需在 RTEngine 拾取后写入。
* 照明:普通=N电(QTN*),应急=E电(QTE*),注释来自协议 CSV。
*/
export type ScreenMode = 'home' | 'pressure' | 'lightN' | 'lightE' | 'fridge' | 'vav' | 'e066'
export const SCREEN_MODES: { id: ScreenMode; label: string }[] = [
{ id: 'home', label: '主页' },
{ id: 'pressure', label: '压差' },
{ id: 'lightN', label: '普通照明' },
{ id: 'lightE', label: '应急照明' },
{ id: 'fridge', label: '冰箱' },
{ id: 'vav', label: '通风柜' },
{ id: 'e066', label: 'E栋1F066冰箱' },
]
export type ModePoiBind =
| { kind: 'room'; roomId: string }
| { kind: 'fridge'; deviceId: string }
| { kind: 'vav'; deviceId: string }
| { kind: 'neLight'; lightId: string }
export type ModePoiDef = {
id: string
mode: Exclude<ScreenMode, 'home' | 'e066'>
label: string
roomHint: string
bind: ModePoiBind
x: number
y: number
z: number
}
/** 压差:一区一 marker(缓冲单独) */
const PRESSURE_POIS: ModePoiDef[] = [
{ id: 'p-seq', mode: 'pressure', label: '测序区', roomHint: '测序区', bind: { kind: 'room', roomId: 'seq' }, x: 0, y: 0, z: 0 },
{ id: 'p-seq-buf', mode: 'pressure', label: '测序区缓冲', roomHint: '测序区缓冲', bind: { kind: 'room', roomId: 'seq-buf' }, x: 0, y: 0, z: 0 },
{ id: 'p-lib-2', mode: 'pressure', label: '文库制备区2', roomHint: '文库制备区2', bind: { kind: 'room', roomId: 'lib-2' }, x: 0, y: 0, z: 0 },
{ id: 'p-lib-2-buf', mode: 'pressure', label: '文库制备区2缓冲', roomHint: '文库制备区2缓冲', bind: { kind: 'room', roomId: 'lib-2-buf' }, x: 0, y: 0, z: 0 },
{ id: 'p-lib-1', mode: 'pressure', label: '文库制备区1', roomHint: '文库制备区1', bind: { kind: 'room', roomId: 'lib-1' }, x: 0, y: 0, z: 0 },
{ id: 'p-lib-1-buf', mode: 'pressure', label: '文库制备区1缓冲', roomHint: '文库制备区1缓冲', bind: { kind: 'room', roomId: 'lib-1-buf' }, x: 0, y: 0, z: 0 },
{ id: 'p-sample-2', mode: 'pressure', label: '样本制备区2', roomHint: '样本制备区2', bind: { kind: 'room', roomId: 'sample-2' }, x: 0, y: 0, z: 0 },
{ id: 'p-sample-1', mode: 'pressure', label: '样本制备区1', roomHint: '样本制备区1', bind: { kind: 'room', roomId: 'sample-1' }, x: 0, y: 0, z: 0 },
{ id: 'p-sample-buf', mode: 'pressure', label: '样本制备区缓冲', roomHint: '样本制备区缓冲', bind: { kind: 'room', roomId: 'sample-buf' }, x: 0, y: 0, z: 0 },
{ id: 'p-recv', mode: 'pressure', label: '收样咨询室', roomHint: '咨询室', bind: { kind: 'room', roomId: 'recv' }, x: 0, y: 0, z: 0 },
{ id: 'p-reagent', mode: 'pressure', label: '试剂准备间', roomHint: '准备区', bind: { kind: 'room', roomId: 'reagent' }, x: 0, y: 0, z: 0 },
{ id: 'p-report', mode: 'pressure', label: '生信分析报告室', roomHint: '报告室', bind: { kind: 'room', roomId: 'report' }, x: 0, y: 0, z: 0 },
{ id: 'p-pcr', mode: 'pressure', label: 'PCR走廊', roomHint: 'PCR走廊', bind: { kind: 'room', roomId: 'pcr-corridor' }, x: 0, y: 0, z: 0 },
]
/** 普通照明 = N电 */
const LIGHT_N_POIS: ModePoiDef[] = [
{ id: 'ln-seq', mode: 'lightN', label: '测序区', roomHint: '测序区', bind: { kind: 'neLight', lightId: 'n-seq' }, x: 0, y: 0, z: 0 },
{ id: 'ln-lib-2', mode: 'lightN', label: '文库制备区2', roomHint: '文库制备区2', bind: { kind: 'neLight', lightId: 'n-lib-2' }, x: 0, y: 0, z: 0 },
{ id: 'ln-lib-1', mode: 'lightN', label: '文库制备区1', roomHint: '文库制备区1', bind: { kind: 'neLight', lightId: 'n-lib-1' }, x: 0, y: 0, z: 0 },
{ id: 'ln-sample-2', mode: 'lightN', label: '样本制备区2', roomHint: '样本制备区2', bind: { kind: 'neLight', lightId: 'n-sample-2' }, x: 0, y: 0, z: 0 },
{ id: 'ln-sample-1', mode: 'lightN', label: '样本制备区1', roomHint: '样本制备区1', bind: { kind: 'neLight', lightId: 'n-sample-1' }, x: 0, y: 0, z: 0 },
{ id: 'ln-report', mode: 'lightN', label: '报告室', roomHint: '报告室', bind: { kind: 'neLight', lightId: 'n-report' }, x: 0, y: 0, z: 0 },
{ id: 'ln-corridor', mode: 'lightN', label: 'NGS走廊', roomHint: 'PCR走廊', bind: { kind: 'neLight', lightId: 'n-corridor' }, x: 0, y: 0, z: 0 },
]
/** 应急照明 = E电 */
const LIGHT_E_POIS: ModePoiDef[] = [
{ id: 'le-seq-buf', mode: 'lightE', label: '测序区缓冲', roomHint: '测序区缓冲', bind: { kind: 'neLight', lightId: 'e-seq-buf' }, x: 0, y: 0, z: 0 },
{ id: 'le-seq', mode: 'lightE', label: '测序区', roomHint: '测序区', bind: { kind: 'neLight', lightId: 'e-seq' }, x: 0, y: 0, z: 0 },
{ id: 'le-lib-2-buf', mode: 'lightE', label: '文库制备区2缓冲', roomHint: '文库制备区2缓冲', bind: { kind: 'neLight', lightId: 'e-lib-2-buf' }, x: 0, y: 0, z: 0 },
{ id: 'le-lib-2', mode: 'lightE', label: '文库制备区2', roomHint: '文库制备区2', bind: { kind: 'neLight', lightId: 'e-lib-2' }, x: 0, y: 0, z: 0 },
{ id: 'le-lib-1-buf', mode: 'lightE', label: '文库制备区1缓冲', roomHint: '文库制备区1缓冲', bind: { kind: 'neLight', lightId: 'e-lib-1-buf' }, x: 0, y: 0, z: 0 },
{ id: 'le-lib-1', mode: 'lightE', label: '文库制备区1', roomHint: '文库制备区1', bind: { kind: 'neLight', lightId: 'e-lib-1' }, x: 0, y: 0, z: 0 },
{ id: 'le-sample-1-buf', mode: 'lightE', label: '样本制备区1缓冲', roomHint: '样本制备区缓冲', bind: { kind: 'neLight', lightId: 'e-sample-1-buf' }, x: 0, y: 0, z: 0 },
{ id: 'le-sample-2', mode: 'lightE', label: '样本制备区2', roomHint: '样本制备区2', bind: { kind: 'neLight', lightId: 'e-sample-2' }, x: 0, y: 0, z: 0 },
{ id: 'le-sample-1', mode: 'lightE', label: '样本制备区1', roomHint: '样本制备区1', bind: { kind: 'neLight', lightId: 'e-sample-1' }, x: 0, y: 0, z: 0 },
{ id: 'le-report', mode: 'lightE', label: '报告室', roomHint: '报告室', bind: { kind: 'neLight', lightId: 'e-report' }, x: 0, y: 0, z: 0 },
{ id: 'le-prep', mode: 'lightE', label: '准备间', roomHint: '准备区', bind: { kind: 'neLight', lightId: 'e-prep' }, x: 0, y: 0, z: 0 },
{ id: 'le-recv', mode: 'lightE', label: '咨询室', roomHint: '咨询室', bind: { kind: 'neLight', lightId: 'e-recv' }, x: 0, y: 0, z: 0 },
{ id: 'le-corridor', mode: 'lightE', label: '走廊', roomHint: 'PCR走廊', bind: { kind: 'neLight', lightId: 'e-corridor' }, x: 0, y: 0, z: 0 },
]
const FRIDGE_POIS: ModePoiDef[] = [
{ id: 'f-recv-1', mode: 'fridge', label: '收样咨询室冰箱1', roomHint: '咨询室', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-3-收样咨询室冰箱1' }, x: 0, y: 0, z: 0 },
{ id: 'f-recv-2', mode: 'fridge', label: '收样咨询室冰箱2', roomHint: '咨询室', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-4-收样咨询室冰箱2' }, x: 0, y: 0, z: 0 },
{ id: 'f-lib-1', mode: 'fridge', label: '文库制备区1冰箱', roomHint: '文库制备区1', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-6-文库制备区1冰箱' }, x: 0, y: 0, z: 0 },
{ id: 'f-lib-2', mode: 'fridge', label: '文库制备区2冰箱', roomHint: '文库制备区2', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-7-文库制备区2冰箱' }, x: 0, y: 0, z: 0 },
{ id: 'f-sample-1', mode: 'fridge', label: '样本制备区1冰箱', roomHint: '样本制备区1', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-5-样本制备区1冰箱' }, x: 0, y: 0, z: 0 },
{ id: 'f-seq', mode: 'fridge', label: '测序区冰箱1', roomHint: '测序区', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-8-测序区冰箱1' }, x: 0, y: 0, z: 0 },
{ id: 'f-reagent-1', mode: 'fridge', label: '试剂准备间冰箱1', roomHint: '准备区', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-1-试剂准备间冰箱1' }, x: 0, y: 0, z: 0 },
{ id: 'f-reagent-2', mode: 'fridge', label: '试剂准备间冰箱2', roomHint: '准备区', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-2-试剂准备间冰箱2' }, x: 0, y: 0, z: 0 },
]
const VAV_POIS: ModePoiDef[] = [
{ id: 'v-lib-1', mode: 'vav', label: '文库制备1通风柜', roomHint: '文库制备区1', bind: { kind: 'vav', deviceId: 'mb-103-文库制备1通风柜' }, x: 0, y: 0, z: 0 },
{ id: 'v-lib-2', mode: 'vav', label: '文库制备2通风柜', roomHint: '文库制备区2', bind: { kind: 'vav', deviceId: 'mb-103-文库制备2通风柜' }, x: 0, y: 0, z: 0 },
]
export const MODE_POIS: ModePoiDef[] = [
...PRESSURE_POIS,
...LIGHT_N_POIS,
...LIGHT_E_POIS,
...FRIDGE_POIS,
...VAV_POIS,
]
export function poisForMode(mode: ScreenMode): ModePoiDef[] {
if (mode === 'home' || mode === 'e066') return []
return MODE_POIS.filter((p) => p.mode === mode)
}
export function fmtNum(v: number | null | undefined, digits = 1): string {
if (v === null || v === undefined || !Number.isFinite(Number(v))) return '--'
return Number(v).toFixed(digits)
}
......@@ -20,20 +20,6 @@ const alarmOpen = ref(false)
const alarmBtnRef = ref<HTMLButtonElement | null>(null)
const alarmPanelStyle = ref<Record<string, string>>({})
const navItems = [
{ to: '/dashboard/overview', label: '全景总览' },
{ to: '/dashboard/roam', label: '漫游巡检' },
{ to: '/dashboard/vav', label: '通风柜VAV' },
{ to: '/dashboard/pressure', label: '房间压差' },
{ to: '/dashboard/hvac', label: '空调排风' },
{ to: '/dashboard/metering', label: '计量计费' },
{ to: '/dashboard/video', label: '视频监控' },
{ to: '/dashboard/access', label: '门禁系统' },
{ to: '/dashboard/cold', label: '冷链监测' },
{ to: '/dashboard/history', label: '历史曲线' },
{ to: '/dashboard/light', label: '智能照明' },
]
const alarmItems = computed(() => {
const alarms = realtime.snapshot.pointAlarms || []
if (!alarms.length) {
......@@ -142,13 +128,6 @@ function goAdmin() {
<main class="screen-main screen-main--float">
<RouterView />
</main>
<!-- 地图右侧功能导航(纵向) -->
<nav class="screen-nav screen-nav--side" aria-label="功能导航">
<RouterLink v-for="item in navItems" :key="item.to" class="nav-chip" :to="item.to">
{{ item.label }}
</RouterLink>
</nav>
</div>
<!-- 告警面板:直接挂 stage 顶层(不用 Teleport,避免打不开) -->
......
......@@ -19,6 +19,7 @@ const empty: RealtimeSnapshot = {
},
vav: [],
fridges: [],
neLights: [],
metering: {
powerKwh: 0,
waterM3: 0,
......
import { ref } from 'vue'
import { defineStore } from 'pinia'
import type { ScreenMode } from '@/data/screenModePois'
export const useScreenModeStore = defineStore('screenMode', () => {
const mode = ref<ScreenMode>('home')
function setMode(next: ScreenMode) {
mode.value = next
}
return { mode, setMode }
})
......@@ -39,6 +39,14 @@ export interface FridgeSnapshot {
humidity: number
}
/** 普通照明(N电) / 应急照明(E电) 回路状态 */
export interface NeLightSnapshot {
id: string
kind: 'N' | 'E'
zone: string
on: boolean
}
export interface DoorSnapshot {
id: string
name: string
......@@ -85,6 +93,7 @@ export interface RealtimeSnapshot {
}
vav: VavSnapshot[]
fridges: FridgeSnapshot[]
neLights?: NeLightSnapshot[]
metering: {
powerKwh: number
waterM3: number
......
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