Commit c29b7735 by James Yuan

fix: 切换镜湖场景并精简大屏离线加载

将 RTEngine 默认场景改为 mvlpj2yt6379aa29,Marker 按经纬度打点;去掉外网 CDN/登录演示口令与告警底栏,加快内网首屏。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 609efc1c
...@@ -60,7 +60,7 @@ VITE_WS_URL= ...@@ -60,7 +60,7 @@ VITE_WS_URL=
# 三维视口:glb | ue-stream | rtengine(RTEngineHD 云渲染 iframe) # 三维视口:glb | ue-stream | rtengine(RTEngineHD 云渲染 iframe)
VITE_VIEWPORT_MODE=rtengine VITE_VIEWPORT_MODE=rtengine
# 推荐同域反代(postMessage 同域);开发/Nginx 已代理 /gis + /peer-stream + /designer + /api/icons # 推荐同域反代(postMessage 同域);开发/Nginx 已代理 /gis + /peer-stream + /designer + /api/icons
VITE_RTENGINE_URL=/gis/gis.html?scene=mvrkctm5c949bf35 VITE_RTENGINE_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
VITE_UE_STREAM_URL=/gis/gis.html?scene=mvrkctm5c949bf35 VITE_UE_STREAM_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
# 反代上游引擎地址(110 本机推流用 127.0.0.1) # 反代上游引擎地址(110 本机推流用 127.0.0.1)
VITE_RTENGINE_PROXY_TARGET=http://127.0.0.1:6088 VITE_RTENGINE_PROXY_TARGET=http://127.0.0.1:6088
...@@ -75,8 +75,8 @@ services: ...@@ -75,8 +75,8 @@ services:
args: args:
VITE_API_BASE: "" VITE_API_BASE: ""
VITE_VIEWPORT_MODE: ${VITE_VIEWPORT_MODE:-rtengine} VITE_VIEWPORT_MODE: ${VITE_VIEWPORT_MODE:-rtengine}
VITE_RTENGINE_URL: ${VITE_RTENGINE_URL:-/gis/gis.html?scene=mvrkctm5c949bf35} VITE_RTENGINE_URL: ${VITE_RTENGINE_URL:-/gis/gis.html?scene=mvlpj2yt6379aa29}
VITE_UE_STREAM_URL: ${VITE_UE_STREAM_URL:-/gis/gis.html?scene=mvrkctm5c949bf35} VITE_UE_STREAM_URL: ${VITE_UE_STREAM_URL:-/gis/gis.html?scene=mvlpj2yt6379aa29}
container_name: jh-lab-web container_name: jh-lab-web
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
......
...@@ -12,8 +12,8 @@ WORKDIR /app/web ...@@ -12,8 +12,8 @@ WORKDIR /app/web
ARG VITE_API_BASE= ARG VITE_API_BASE=
ARG VITE_VIEWPORT_MODE=rtengine ARG VITE_VIEWPORT_MODE=rtengine
ARG VITE_RTENGINE_URL=/gis/gis.html?scene=mvrkctm5c949bf35 ARG VITE_RTENGINE_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
ARG VITE_UE_STREAM_URL=/gis/gis.html?scene=mvrkctm5c949bf35 ARG VITE_UE_STREAM_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
ENV VITE_API_BASE=$VITE_API_BASE \ ENV VITE_API_BASE=$VITE_API_BASE \
VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \ VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \
VITE_RTENGINE_URL=$VITE_RTENGINE_URL \ VITE_RTENGINE_URL=$VITE_RTENGINE_URL \
......
...@@ -7,10 +7,7 @@ ...@@ -7,10 +7,7 @@
<meta name="theme-color" content="#0a1624" /> <meta name="theme-color" content="#0a1624" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>分子实验室三维可视化管理平台</title> <title>分子实验室三维可视化管理平台</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <!-- 实验室内网离线:不引用 fonts.googleapis / unpkg 等外网 CDN -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet" />
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
......
...@@ -7,6 +7,7 @@ import { useScreenModeStore } from '@/stores/screenMode' ...@@ -7,6 +7,7 @@ import { useScreenModeStore } from '@/stores/screenMode'
import { SCREEN_AREAS } from '@/data/rooms' import { SCREEN_AREAS } from '@/data/rooms'
import { import {
DEVICE_MENU, DEVICE_MENU,
DEVICE_MENU_ICON,
categoryOfMarker, categoryOfMarker,
markerReadout, markerReadout,
type ScreenMode, type ScreenMode,
...@@ -51,7 +52,7 @@ const mode = (import.meta.env.VITE_VIEWPORT_MODE || 'rtengine') as ViewportMode ...@@ -51,7 +52,7 @@ const mode = (import.meta.env.VITE_VIEWPORT_MODE || 'rtengine') as ViewportMode
const cloudUrl = const cloudUrl =
(import.meta.env.VITE_RTENGINE_URL || '').trim() || (import.meta.env.VITE_RTENGINE_URL || '').trim() ||
(import.meta.env.VITE_UE_STREAM_URL || '').trim() || (import.meta.env.VITE_UE_STREAM_URL || '').trim() ||
'/gis/gis.html?scene=mvrkctm5c949bf35' '/gis/gis.html?scene=mvlpj2yt6379aa29'
const useCloudFrame = computed( const useCloudFrame = computed(
() => () =>
...@@ -417,24 +418,25 @@ async function onEngineReady() { ...@@ -417,24 +418,25 @@ async function onEngineReady() {
if (!useCloudFrame.value || engineReadyDone || engineBootstrapping) return if (!useCloudFrame.value || engineReadyDone || engineBootstrapping) return
engineBootstrapping = true engineBootstrapping = true
try { try {
await preloadRtengineIcons(undefined, cloudUrl) // 图标预载与拉摄像台账并行,缩短首屏等待
// 不调用 showScenePresets(会全开 POI);刷新后强制默认压差并卸旧打新 const iconsP = preloadRtengineIcons(undefined, cloudUrl).catch(() => undefined)
const camsP = loadCameras()
screenMode.setMode('pressure') screenMode.setMode('pressure')
await loadCameras() modePoisBootstrapped.value = true
flyToViewpoint(DEFAULT_BOOKMARK)
await Promise.all([iconsP, camsP])
syncCameraMarkers(true) syncCameraMarkers(true)
applyCameraVisibility() applyCameraVisibility()
// 先飞默认相机;飞完再按压差分类 rebuild(setMode 同值时 watch 不触发,必须显式 rebuild) // 经纬度 Marker:与飞镜并行打点,不再人为空等 2.8s
flyToViewpoint(DEFAULT_BOOKMARK)
await new Promise((r) => window.setTimeout(r, 2500))
screenMode.setMode('pressure') screenMode.setMode('pressure')
await syncModePoisRebuild() await syncModePoisRebuild()
window.setTimeout(() => { window.setTimeout(() => {
if (!markersSynced) syncCameraMarkers(true) if (!markersSynced) syncCameraMarkers(true)
applyCameraVisibility() applyCameraVisibility()
screenMode.setMode('pressure')
void syncModePoisRebuild() void syncModePoisRebuild()
}, 2000) }, 1200)
} catch { } catch {
modePoisBootstrapped.value = true
syncCameraMarkers(true) syncCameraMarkers(true)
} finally { } finally {
engineBootstrapping = false engineBootstrapping = false
...@@ -572,8 +574,17 @@ function onMarkerClick(payload: { ...@@ -572,8 +574,17 @@ function onMarkerClick(payload: {
nearKind.value = 'video' nearKind.value = 'video'
nearLines.value = [] nearLines.value = []
nearSensorId.value = camSensor?.id || null nearSensorId.value = camSensor?.id || null
// 优先视频台账 id;否则用传感器绑定的 videoDeviceId / 稳定 cam-* code // 优先 cam-{末段 IP},与网关/MediaMTX 流名一致;避免 UUID 解析失败落到 demo mp4
const ipHit = String(cam?.ip || '').match(/^192\.168\.10\.(\d+)$/)
const ipCam = ipHit ? `cam-${ipHit[1]}` : null
const bound =
(camSensor?.videoDeviceId && /^cam-\d+$/i.test(camSensor.videoDeviceId)
? camSensor.videoDeviceId
: null) ||
(cam?.streamId && /^cam-\d+$/i.test(String(cam.streamId)) ? String(cam.streamId) : null) ||
ipCam
nearDeviceId.value = nearDeviceId.value =
bound ||
cam?.id || cam?.id ||
camSensor?.videoDeviceId || camSensor?.videoDeviceId ||
(camSensor?.code && /^cam-\d+$/i.test(camSensor.code) ? camSensor.code : null) || (camSensor?.code && /^cam-\d+$/i.test(camSensor.code) ? camSensor.code : null) ||
...@@ -618,7 +629,11 @@ const bridge = useRtengineBridge({ ...@@ -618,7 +629,11 @@ const bridge = useRtengineBridge({
onMouseClick: onMarkerClick, onMouseClick: onMarkerClick,
}) })
const modePoisEnabled = computed(() => props.layout === 'fill' && useCloudFrame.value) /** 等默认书签飞完再开 POI,避免 ready 瞬间打点时模型/坐标尚未对齐 */
const modePoisBootstrapped = ref(false)
const modePoisEnabled = computed(
() => props.layout === 'fill' && useCloudFrame.value && modePoisBootstrapped.value,
)
const { syncModePoisRebuild } = useScreenModePois(bridge, modePoisEnabled, cloudUrl) const { syncModePoisRebuild } = useScreenModePois(bridge, modePoisEnabled, cloudUrl)
const { syncLightEffects } = useLightSceneEffects(bridge, modePoisEnabled) const { syncLightEffects } = useLightSceneEffects(bridge, modePoisEnabled)
...@@ -649,7 +664,7 @@ async function loadCameras() { ...@@ -649,7 +664,7 @@ async function loadCameras() {
function onCloudFrameLoad() { function onCloudFrameLoad() {
bindIframePointer() bindIframePointer()
// iframe 文档就绪后稍等信令/场景,再触发一次就绪管线 // iframe 文档就绪后稍等信令/场景,再触发一次就绪管线
window.setTimeout(() => bridge.markReady(), 2500) window.setTimeout(() => bridge.markReady(), 1200)
} }
type SmoothControlsLike = { type SmoothControlsLike = {
...@@ -804,7 +819,7 @@ onMounted(() => { ...@@ -804,7 +819,7 @@ onMounted(() => {
} }
if (useCloudFrame.value) { if (useCloudFrame.value) {
// 兜底:部分环境下无 sceneEmbedLoaded,延时强制走就绪管线 // 兜底:部分环境下无 sceneEmbedLoaded,延时强制走就绪管线
window.setTimeout(() => bridge.markReady(), 6000) window.setTimeout(() => bridge.markReady(), 3500)
return return
} }
const el = viewerRef.value const el = viewerRef.value
...@@ -831,9 +846,8 @@ onBeforeUnmount(() => { ...@@ -831,9 +846,8 @@ onBeforeUnmount(() => {
<span class="view">{{ modeLabel }}</span> <span class="view">{{ modeLabel }}</span>
</div> </div>
<!-- 全屏:右侧设备分类(只切图标)+ 底栏区域飞视 --> <!-- 全屏:右侧设备分类(只切图标) -->
<Teleport v-if="props.layout === 'fill' && floatTargetReady" to="#screen-float-overlays" defer> <Teleport v-if="props.layout === 'fill' && floatTargetReady" to="#screen-float-overlays" defer>
<!-- 右侧:设备分类。点一类只显示该类图标;全景总览在底栏测序区前面 -->
<div class="vp-area-side" :aria-label="t('screen.deviceCategory')"> <div class="vp-area-side" :aria-label="t('screen.deviceCategory')">
<button <button
v-for="item in DEVICE_MENU" v-for="item in DEVICE_MENU"
...@@ -843,33 +857,10 @@ onBeforeUnmount(() => { ...@@ -843,33 +857,10 @@ onBeforeUnmount(() => {
:class="{ active: item.id === activeMode }" :class="{ active: item.id === activeMode }"
@click="onSelectMode(item.id)" @click="onSelectMode(item.id)"
> >
<AppIcon :name="DEVICE_MENU_ICON[item.id]" class-name="room-ico" />
<span class="name">{{ t(`modes.${item.id}`) }}</span> <span class="name">{{ t(`modes.${item.id}`) }}</span>
</button> </button>
</div> </div>
<!-- 底栏:全景总览在测序区前面,其后为各区域飞视 -->
<div class="vp-mode-bar" :aria-label="t('screen.areaFly')">
<button
type="button"
class="vp-mode-item"
:class="{ active: activeAreaId === OVERVIEW_AREA_ID }"
@click="onSelectOverview"
>
<AppIcon name="pin" class-name="room-ico" />
<span>{{ t('screen.overview') }}</span>
</button>
<button
v-for="r in areaList"
:key="r.id"
type="button"
class="vp-mode-item"
:class="{ active: r.id === activeAreaId, alarm: r.alarms.length > 0 }"
@click="onSelectArea(r.id)"
>
<AppIcon name="pin" class-name="room-ico" />
<span>{{ t(`areas.${r.id}`) }}</span>
</button>
</div>
</Teleport> </Teleport>
<div v-else-if="props.layout !== 'fill'" class="vp-room-panel"> <div v-else-if="props.layout !== 'fill'" class="vp-room-panel">
<div class="vp-room-head"> <div class="vp-room-head">
...@@ -1020,23 +1011,31 @@ onBeforeUnmount(() => { ...@@ -1020,23 +1011,31 @@ onBeforeUnmount(() => {
.vp-area-side-item { .vp-area-side-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start;
gap: 6px; gap: 6px;
min-width: 108px; width: auto;
padding: 8px 12px; min-width: 0;
max-width: 108px;
padding: 7px 8px;
border-radius: 4px; border-radius: 4px;
border: 1px solid rgba(91, 212, 255, 0.32); border: 1px solid rgba(91, 212, 255, 0.32);
background: rgba(8, 28, 58, 0.62); background: rgba(8, 28, 58, 0.62);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
color: rgba(235, 245, 255, 0.95); color: rgba(235, 245, 255, 0.95);
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
} }
.vp-area-side-item :deep(.room-ico) { .vp-area-side-item :deep(.room-ico) {
width: 12px; width: 14px;
height: 12px; height: 14px;
opacity: 0.9; flex: 0 0 auto;
opacity: 0.95;
}
.vp-area-side-item .name {
overflow: hidden;
text-overflow: ellipsis;
} }
.vp-area-side-item:hover { .vp-area-side-item:hover {
border-color: rgba(62, 200, 255, 0.5); border-color: rgba(62, 200, 255, 0.5);
...@@ -1051,55 +1050,6 @@ onBeforeUnmount(() => { ...@@ -1051,55 +1050,6 @@ onBeforeUnmount(() => {
color: #ffb0b0; color: #ffb0b0;
} }
/* 底栏七态(参考 McgsPro) */
.vp-mode-bar {
position: absolute;
z-index: 8;
left: 50%;
bottom: clamp(12px, 2vh, 24px);
transform: translateX(-50%);
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
max-width: calc(100vw - 2 * var(--screen-side-w, 350px) - 36px);
overflow-x: auto;
pointer-events: auto;
}
.vp-mode-item {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
min-width: 0;
padding: 8px 12px;
border-radius: 4px;
border: 1px solid rgba(91, 212, 255, 0.32);
background: rgba(8, 28, 58, 0.72);
backdrop-filter: blur(6px);
color: rgba(230, 240, 255, 0.92);
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.vp-mode-item :deep(.room-ico) {
width: 12px;
height: 12px;
opacity: 0.9;
}
.vp-mode-item:hover {
border-color: rgba(91, 212, 255, 0.5);
background: rgba(30, 82, 150, 0.7);
}
.vp-mode-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-mode-item.alarm {
color: #ffb0b0;
}
.vp-room-head { .vp-room-head {
display: flex; align-items: center; justify-content: center; gap: 6px; display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 7px 8px; padding: 7px 8px;
......
...@@ -17,6 +17,10 @@ export type RtengineMarker = { ...@@ -17,6 +17,10 @@ export type RtengineMarker = {
tag0?: string tag0?: string
tag1?: string tag1?: string
scale?: number scale?: number
/** 非等比缩放(拉高/压扁图标);未设则回落 scale */
scaleX?: number
scaleY?: number
scaleZ?: number
/** 引擎图标文件名,如 cctv-blue.png */ /** 引擎图标文件名,如 cctv-blue.png */
icon?: string icon?: string
/** 是否显示地面引线;默认 true */ /** 是否显示地面引线;默认 true */
...@@ -117,7 +121,7 @@ export type EnvLightingSnapshot = { ...@@ -117,7 +121,7 @@ export type EnvLightingSnapshot = {
} }
/** 默认走同域 /gis 反代,避免 iframe postMessage 跨域 */ /** 默认走同域 /gis 反代,避免 iframe postMessage 跨域 */
export const RTENGINE_SCENE_QUERY = 'scene=mvrkctm5c949bf35' export const RTENGINE_SCENE_QUERY = 'scene=mvlpj2yt6379aa29'
export const RTENGINE_PROXY_PATH = '/gis' export const RTENGINE_PROXY_PATH = '/gis'
export function defaultRtengineUrl() { export function defaultRtengineUrl() {
...@@ -546,11 +550,15 @@ export function useRtengineBridge(opts: { ...@@ -546,11 +550,15 @@ export function useRtengineBridge(opts: {
function markerPayload(m: RtengineMarker) { function markerPayload(m: RtengineMarker) {
const icon = (m.icon || '').trim() const icon = (m.icon || '').trim()
const scale = m.scale ?? 1 const scale = m.scale ?? 1
const scaleX = m.scaleX ?? scale
const scaleY = m.scaleY ?? scale
const scaleZ = m.scaleZ ?? scale
const leader = m.leader !== false const leader = m.leader !== false
const leaderHeight = const leaderHeight =
m.leaderHeight != null && Number(m.leaderHeight) > 0 m.leaderHeight != null && Number(m.leaderHeight) > 0
? Number(m.leaderHeight) ? Number(m.leaderHeight)
: MARKER_LEADER_HEIGHT_M : MARKER_LEADER_HEIGHT_M
// 设施打点保存的是经纬度(与后台「三维打点」一致),必须 isGeo:true
return { return {
id: m.id, id: m.id,
name: m.name, name: m.name,
...@@ -560,7 +568,7 @@ export function useRtengineBridge(opts: { ...@@ -560,7 +568,7 @@ export function useRtengineBridge(opts: {
visible: true, visible: true,
show: true, show: true,
type: m.type || 'marker', type: m.type || 'marker',
// 引擎同时认 x/y/z 与 lng/lat/height isGeo: true,
x: m.x, x: m.x,
y: m.y, y: m.y,
z: m.z, z: m.z,
...@@ -569,10 +577,14 @@ export function useRtengineBridge(opts: { ...@@ -569,10 +577,14 @@ export function useRtengineBridge(opts: {
height: m.z, height: m.z,
longitude: m.x, longitude: m.x,
latitude: m.y, latitude: m.y,
scale, position: [m.x, m.y, m.z],
scalex: scale, scale: scaleY,
scaley: scale, scalex: scaleX,
scalez: scale, scaley: scaleY,
scalez: scaleZ,
scaleX,
scaleY,
scaleZ,
tag0: m.tag0 || 'camera', tag0: m.tag0 || 'camera',
tag1: m.tag1 || m.id, tag1: m.tag1 || m.id,
// 地面锚点 → 图标引线(引擎 leader / leaderHeight) // 地面锚点 → 图标引线(引擎 leader / leaderHeight)
......
...@@ -7,6 +7,9 @@ import { ...@@ -7,6 +7,9 @@ import {
DEVICE_TAG, DEVICE_TAG,
MODE_MARKER_ICON, MODE_MARKER_ICON,
MODE_MARKER_SCALE, MODE_MARKER_SCALE,
MODE_MARKER_SCALE_X,
MODE_MARKER_SCALE_Y,
MODE_MARKER_SCALE_Z,
MODE_POIS, MODE_POIS,
fmtNum, fmtNum,
poisForMode, poisForMode,
...@@ -107,6 +110,9 @@ function toPoiMarker(def: ModePoiDef, snap: RealtimeSnapshot): RtengineMarker { ...@@ -107,6 +110,9 @@ function toPoiMarker(def: ModePoiDef, snap: RealtimeSnapshot): RtengineMarker {
tag0: DEVICE_TAG[def.mode], tag0: DEVICE_TAG[def.mode],
tag1: def.id, tag1: def.id,
scale: MODE_MARKER_SCALE, scale: MODE_MARKER_SCALE,
scaleX: MODE_MARKER_SCALE_X,
scaleY: MODE_MARKER_SCALE_Y,
scaleZ: MODE_MARKER_SCALE_Z,
icon: MODE_MARKER_ICON[def.mode], icon: MODE_MARKER_ICON[def.mode],
...MARKER_LEADER, ...MARKER_LEADER,
} }
...@@ -144,6 +150,9 @@ function toSensorMarker(s: SensorSnapshot): RtengineMarker { ...@@ -144,6 +150,9 @@ function toSensorMarker(s: SensorSnapshot): RtengineMarker {
tag0, tag0,
tag1: s.id, tag1: s.id,
scale: MODE_MARKER_SCALE, scale: MODE_MARKER_SCALE,
scaleX: MODE_MARKER_SCALE_X,
scaleY: MODE_MARKER_SCALE_Y,
scaleZ: MODE_MARKER_SCALE_Z,
icon: s.category === 'pressure' ? MODE_MARKER_ICON.pressure : resolveFacilityIcon(s), icon: s.category === 'pressure' ? MODE_MARKER_ICON.pressure : resolveFacilityIcon(s),
...MARKER_LEADER, ...MARKER_LEADER,
} }
......
...@@ -33,7 +33,11 @@ export const DEVICE_TAG: Record<DeviceCategory, string> = { ...@@ -33,7 +33,11 @@ export const DEVICE_TAG: Record<DeviceCategory, string> = {
access: 'access', access: 'access',
} }
/** 横向基准;纵向单独拉高,避免图标看起来偏扁 */
export const MODE_MARKER_SCALE = 0.5 export const MODE_MARKER_SCALE = 0.5
export const MODE_MARKER_SCALE_X = 0.48
export const MODE_MARKER_SCALE_Y = 0.72
export const MODE_MARKER_SCALE_Z = 0.48
export const SCREEN_MODES: { id: ScreenMode; label: string }[] = [ export const SCREEN_MODES: { id: ScreenMode; label: string }[] = [
{ id: 'home', label: '全景总览' }, { id: 'home', label: '全景总览' },
...@@ -47,8 +51,21 @@ export const SCREEN_MODES: { id: ScreenMode; label: string }[] = [ ...@@ -47,8 +51,21 @@ export const SCREEN_MODES: { id: ScreenMode; label: string }[] = [
{ id: 'e066', label: 'E栋1F066冰箱' }, { id: 'e066', label: 'E栋1F066冰箱' },
] ]
/** 三维区域右侧分类(不含全景总览、E栋卡片;全景总览在底栏区域按钮最前) */ /** 三维区域右侧分类(不含全景总览、E栋卡片) */
export const DEVICE_MENU = SCREEN_MODES.filter((m) => m.id !== 'e066' && m.id !== 'home') export const DEVICE_MENU: { id: DeviceCategory; label: string }[] = SCREEN_MODES.filter(
(m): m is { id: DeviceCategory; label: string } => m.id !== 'e066' && m.id !== 'home',
)
/** 右侧分类按钮图标(AppIcon name) */
export const DEVICE_MENU_ICON: Record<DeviceCategory, string> = {
camera: 'panel-video',
access: 'kpi-access',
pressure: 'metric-pressure',
lightN: 'kpi-power',
lightE: 'alarm-amber',
fridge: 'fridge',
vav: 'panel-vav',
}
export type ModePoiBind = export type ModePoiBind =
| { kind: 'room'; roomId: string } | { kind: 'room'; roomId: string }
......
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
...@@ -10,7 +10,6 @@ import { useStageScale } from '@/composables/useStageScale' ...@@ -10,7 +10,6 @@ import { useStageScale } from '@/composables/useStageScale'
import ScreenHeader from '@/components/screen/ScreenHeader.vue' import ScreenHeader from '@/components/screen/ScreenHeader.vue'
import LanguageSwitch from '@/components/screen/LanguageSwitch.vue' import LanguageSwitch from '@/components/screen/LanguageSwitch.vue'
import SceneViewport from '@/components/scene/SceneViewport.vue' import SceneViewport from '@/components/scene/SceneViewport.vue'
import AppIcon from '@/components/icons/AppIcon.vue'
import { useScreenTitle } from '@/composables/useScreenTitle' import { useScreenTitle } from '@/composables/useScreenTitle'
const auth = useAuthStore() const auth = useAuthStore()
...@@ -21,64 +20,15 @@ const { time, date } = useClock() ...@@ -21,64 +20,15 @@ const { time, date } = useClock()
const { place, temp, text: weatherText, humidity } = useMacauWeather() const { place, temp, text: weatherText, humidity } = useMacauWeather()
const { style } = useStageScale() const { style } = useStageScale()
const { title: screenTitle } = useScreenTitle() const { title: screenTitle } = useScreenTitle()
const alarmOpen = ref(false)
const alarmBtnRef = ref<HTMLButtonElement | null>(null)
const alarmPanelStyle = ref<Record<string, string>>({})
const alarmItems = computed(() => {
const alarms = realtime.snapshot.pointAlarms || []
if (!alarms.length) {
return [
{
level: 'calm' as const,
title: t('screen.systemOk'),
sub: t('screen.monitoring'),
state: t('screen.normal'),
},
]
}
return alarms.slice(0, 12).map((a) => ({
level: (a.level === 'red' ? 'red' : 'amber') as 'red' | 'amber',
title: a.message,
sub: a.name || t('screen.pointOverLimit'),
state: t('screen.watching'),
}))
})
onMounted(() => { onMounted(() => {
realtime.connect() realtime.connect()
if (auth.isLoggedIn && !auth.user) auth.fetchMe().catch(() => undefined) if (auth.isLoggedIn && !auth.user) auth.fetchMe().catch(() => undefined)
window.addEventListener('keydown', onKey)
}) })
onUnmounted(() => { onUnmounted(() => {
realtime.disconnect() realtime.disconnect()
window.removeEventListener('keydown', onKey)
}) })
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') alarmOpen.value = false
}
function placeAlarmPanel() {
const el = alarmBtnRef.value
const stage = el?.closest('.stage') as HTMLElement | null
if (!el || !stage) return
const br = el.getBoundingClientRect()
const sr = stage.getBoundingClientRect()
alarmPanelStyle.value = {
top: `${Math.round(br.bottom - sr.top + 8)}px`,
right: `${Math.round(sr.right - br.right)}px`,
}
}
async function toggleAlarm() {
alarmOpen.value = !alarmOpen.value
if (alarmOpen.value) {
await nextTick()
placeAlarmPanel()
}
}
function goAdmin() { function goAdmin() {
if (auth.isLoggedIn) router.push('/admin') if (auth.isLoggedIn) router.push('/admin')
else router.push({ path: '/login', query: { redirect: '/admin' } }) else router.push({ path: '/login', query: { redirect: '/admin' } })
...@@ -120,20 +70,6 @@ function goAdmin() { ...@@ -120,20 +70,6 @@ function goAdmin() {
<span class="date">{{ date }}</span> <span class="date">{{ date }}</span>
</div> </div>
<LanguageSwitch /> <LanguageSwitch />
<div class="alarm-wrap">
<button
ref="alarmBtnRef"
type="button"
class="alarm-badge"
:class="{ open: alarmOpen }"
aria-haspopup="true"
:aria-expanded="alarmOpen"
@click.stop="toggleAlarm"
>
<span class="dot red" />
<span>{{ t('screen.alarm') }} {{ realtime.alarmCount }}</span>
</button>
</div>
<button class="tops-logout admin-entry" type="button" @click="goAdmin"> <button class="tops-logout admin-entry" type="button" @click="goAdmin">
{{ t('screen.admin') }} {{ t('screen.admin') }}
</button> </button>
...@@ -144,39 +80,6 @@ function goAdmin() { ...@@ -144,39 +80,6 @@ function goAdmin() {
<RouterView /> <RouterView />
</main> </main>
</div> </div>
<!-- 告警面板:直接挂 stage 顶层(不用 Teleport,避免打不开) -->
<div v-if="alarmOpen" class="alarm-backdrop" @click="alarmOpen = false" />
<div
v-if="alarmOpen"
class="alarm-dropdown"
role="dialog"
:aria-label="t('screen.alarmList')"
:style="alarmPanelStyle"
@click.stop
>
<div class="alarm-dd-head">
<span>{{ t('screen.currentAlarms') }}</span>
<b>{{ realtime.alarmCount }}</b>
</div>
<div class="alarm-dd-list">
<div
v-for="(a, i) in alarmItems"
:key="`${a.title}-${i}`"
class="alarm-dd-item"
:class="a.level"
>
<AppIcon
:name="a.level === 'red' ? 'alarm-warn' : a.level === 'amber' ? 'alarm-amber' : 'alarm-ok'"
/>
<div class="a-txt">
<div class="a-title">{{ a.title }}</div>
<div class="a-sub">{{ a.sub }}</div>
</div>
<div class="a-state">{{ a.state }}</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
...@@ -277,51 +180,7 @@ function goAdmin() { ...@@ -277,51 +180,7 @@ function goAdmin() {
.screen-float-overlays > * { .screen-float-overlays > * {
pointer-events: auto; pointer-events: auto;
} }
/* scene 0 < edge 1 < ui 2 < float 5 < side-nav 6 < alarm 200 */ /* scene 0 < edge 1 < ui 2 < float 5 < side-nav 6 */
/* 贴在右侧业务面板左侧,纵向功能按钮(无背景面板)
* 覆盖全局 .screen-nav 的 height:48px / flex:0 0 48px,否则只露出 1 个按钮 */
.screen-nav--side {
position: absolute;
z-index: 6;
/* 避开顶栏 + 中间翻牌器;贴在右侧面板内侧 */
top: clamp(200px, 26vh, 280px);
bottom: clamp(56px, 8vh, 88px);
right: calc(var(--screen-side-w, 350px) + 28px);
width: clamp(88px, 6vw, 108px);
height: auto !important;
flex: none !important;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
backdrop-filter: none;
box-shadow: none;
pointer-events: auto;
overflow-x: hidden;
overflow-y: auto;
}
.screen-nav--side :deep(.nav-chip),
.screen-nav--side .nav-chip {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 100%;
min-height: 34px;
padding: 7px 6px;
text-align: center;
font-size: 11px;
line-height: 1.25;
white-space: normal;
border-radius: 4px;
background: rgba(8, 28, 58, 0.55);
border: 1px solid rgba(91, 212, 255, 0.32);
backdrop-filter: blur(6px);
}
.weather-wrap, .weather-wrap,
.clock-wrap { .clock-wrap {
position: static; position: static;
...@@ -334,7 +193,7 @@ function goAdmin() { ...@@ -334,7 +193,7 @@ function goAdmin() {
overflow: hidden; overflow: hidden;
min-width: 0; min-width: 0;
} }
/* 右上角告警/管理优先单行;窄屏时先收起左侧天气与时钟次要信息 */ /* 窄屏时先收起左侧天气与时钟次要信息 */
@media (max-width: 1400px) { @media (max-width: 1400px) {
.weather-wrap .w-text, .weather-wrap .w-text,
.weather-wrap .humid { .weather-wrap .humid {
...@@ -385,114 +244,4 @@ function goAdmin() { ...@@ -385,114 +244,4 @@ function goAdmin() {
white-space: nowrap !important; white-space: nowrap !important;
flex-shrink: 0; flex-shrink: 0;
} }
.alarm-wrap {
position: relative;
pointer-events: auto;
flex-shrink: 0;
}
.alarm-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font: inherit;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.alarm-badge > span {
white-space: nowrap;
}
.alarm-badge.open {
border-color: rgba(255, 120, 120, 0.75);
background: rgba(80, 16, 24, 0.55);
}
.alarm-backdrop {
position: absolute;
inset: 0;
z-index: 200;
background: transparent;
pointer-events: auto;
}
.alarm-dropdown {
position: absolute;
z-index: 201;
width: min(360px, 72vw);
max-height: min(420px, 60vh);
display: flex;
flex-direction: column;
border-radius: 6px;
border: 1px solid rgba(255, 120, 120, 0.35);
background: linear-gradient(180deg, rgba(28, 10, 16, 0.96), rgba(8, 14, 28, 0.96));
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(10px);
overflow: hidden;
pointer-events: auto;
}
.alarm-dd-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
font-size: 12px;
color: #ffc9c9;
border-bottom: 1px solid rgba(255, 120, 120, 0.22);
}
.alarm-dd-head b {
font-family: var(--digital-font);
color: #ff8f8f;
}
.alarm-dd-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.alarm-dd-item {
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 6px 8px;
border-radius: 5px;
font-size: 12px;
}
.alarm-dd-item.red {
background: rgba(255, 82, 82, 0.12);
border: 1px solid rgba(255, 82, 82, 0.35);
}
.alarm-dd-item.amber {
background: rgba(255, 180, 0, 0.1);
border: 1px solid rgba(255, 180, 0, 0.3);
}
.alarm-dd-item.calm {
background: rgba(26, 77, 153, 0.22);
border: 1px solid transparent;
}
.alarm-dd-item .a-txt {
flex: 1;
min-width: 0;
}
.alarm-dd-item .a-title {
color: #f2f7ff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.alarm-dd-item .a-sub {
margin-top: 2px;
font-size: 10px;
color: rgba(185, 214, 240, 0.72);
}
.alarm-dd-item .a-state {
flex-shrink: 0;
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.28);
color: #ffd0d0;
white-space: nowrap;
}
</style> </style>
...@@ -28,7 +28,8 @@ ...@@ -28,7 +28,8 @@
--title-font: 'AlibabaPuHuiTi-3-115-Black', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', --title-font: 'AlibabaPuHuiTi-3-115-Black', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei',
sans-serif; sans-serif;
--body-font: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif; --body-font: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
--digital-font: 'Share Tech Mono', ui-monospace, monospace; /* 离线环境不用 Google Fonts;系统等宽即可 */
--digital-font: ui-monospace, 'SF Mono', Menlo, Consolas, 'Courier New', monospace;
--cjk-tracking: 0.02em; --cjk-tracking: 0.02em;
/* Backward-compatible aliases used by existing views */ /* Backward-compatible aliases used by existing views */
......
...@@ -6,8 +6,8 @@ import { ApiError } from '@/api/http' ...@@ -6,8 +6,8 @@ import { ApiError } from '@/api/http'
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
const username = ref('admin') const username = ref('')
const password = ref('Admin@123') const password = ref('')
const error = ref('') const error = ref('')
const loading = ref(false) const loading = ref(false)
const shake = ref(false) const shake = ref(false)
...@@ -70,9 +70,6 @@ async function submit() { ...@@ -70,9 +70,6 @@ async function submit() {
<span>{{ loading ? '登录中…' : '进入后台' }}</span> <span>{{ loading ? '登录中…' : '进入后台' }}</span>
</button> </button>
<p class="tops-login__hint">
演示账号 <code>admin / Admin@123</code> · <code>viewer / Viewer@123</code>
</p>
<button class="tops-login__dash" type="button" @click="router.push('/dashboard')"> <button class="tops-login__dash" type="button" @click="router.push('/dashboard')">
无需登录,进入大屏 无需登录,进入大屏
</button> </button>
......
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