|
|
@@ -0,0 +1,378 @@
|
|
|
+<!-- MonitorPanel.vue 完整版(支持销毁) -->
|
|
|
+<template>
|
|
|
+ <!-- 外层动画包裹层 -->
|
|
|
+ <div class="panel-wrapper">
|
|
|
+ <div class="monitor-panel" ref="panelRef" :class="{ alarm: hasAlarm }">
|
|
|
+ <div class="panel-title">{{ sensorData.positionName || 'XXXXX位置' }}</div>
|
|
|
+ <div class="panel-grid">
|
|
|
+ <div class="panel-item">
|
|
|
+ <span class="item-label">▸ 微震测声</span>
|
|
|
+ <span class="item-value" :class="getStatusClass('microseism')">
|
|
|
+ {{ sensorData.microseism ?? 'XXXX' }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="panel-item">
|
|
|
+ <span class="item-label">▸ 光纤测温</span>
|
|
|
+ <span class="item-value" :class="getStatusClass('fiberTemp')">
|
|
|
+ {{ sensorData.fiberTemp ?? 'XXXX' }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="panel-item">
|
|
|
+ <span class="item-label">▸ 温度</span>
|
|
|
+ <span class="item-value" :class="getStatusClass('temperature')">
|
|
|
+ {{ sensorData.temperature ?? 'XXXX' }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="panel-item">
|
|
|
+ <span class="item-label">▸ 火焰</span>
|
|
|
+ <span class="item-value" :class="getStatusClass('flame')">
|
|
|
+ {{ sensorData.flame ?? 'XXXX' }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="panel-item item-full">
|
|
|
+ <span class="item-label ai-label">▸ AI视频火焰识别</span>
|
|
|
+ <span class="item-value" :class="getStatusClass('aiFlame')">
|
|
|
+ {{ sensorData.aiFlame ?? 'XXXX' }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="panel-item">
|
|
|
+ <span class="item-label">▸ HCl</span>
|
|
|
+ <span class="item-value" :class="getStatusClass('hcl')">
|
|
|
+ {{ sensorData.hcl ?? 'XXXX' }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="panel-item">
|
|
|
+ <span class="item-label">▸ CO</span>
|
|
|
+ <span class="item-value" :class="getStatusClass('co')">
|
|
|
+ {{ sensorData.co ?? 'XXXX' }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="panel-item">
|
|
|
+ <span class="item-label">▸ 烟雾</span>
|
|
|
+ <span class="item-value" :class="getStatusClass('smoke')">
|
|
|
+ {{ sensorData.smoke ?? 'XXXX' }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="panel-item">
|
|
|
+ <span class="item-label">▸ 多参数</span>
|
|
|
+ <span class="item-value" :class="getStatusClass('multiParam')">
|
|
|
+ {{ sensorData.multiParam ?? 'XXXX' }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="panel-arrow"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+ import { ref, onMounted, computed, onUnmounted } from 'vue';
|
|
|
+ import { CSS3DObject } from 'three/examples/jsm/renderers/CSS3DRenderer.js';
|
|
|
+ import type { Scene, Camera } from 'three';
|
|
|
+
|
|
|
+ export interface AlarmThreshold {
|
|
|
+ microseism?: number;
|
|
|
+ hcl?: number;
|
|
|
+ fiberTemp?: number;
|
|
|
+ co?: number;
|
|
|
+ temperature?: number;
|
|
|
+ smoke?: number;
|
|
|
+ flame?: number;
|
|
|
+ multiParam?: number;
|
|
|
+ aiFlame?: number;
|
|
|
+ }
|
|
|
+
|
|
|
+ export interface SensorData {
|
|
|
+ positionName?: string;
|
|
|
+ microseism?: number;
|
|
|
+ hcl?: number;
|
|
|
+ fiberTemp?: number;
|
|
|
+ co?: number;
|
|
|
+ temperature?: number;
|
|
|
+ smoke?: number;
|
|
|
+ flame?: number;
|
|
|
+ multiParam?: number;
|
|
|
+ aiFlame?: number;
|
|
|
+ }
|
|
|
+
|
|
|
+ const props = defineProps({
|
|
|
+ sensorData: {
|
|
|
+ type: Object as () => SensorData,
|
|
|
+ default: () => ({}),
|
|
|
+ },
|
|
|
+ threshold: {
|
|
|
+ type: Object as () => AlarmThreshold,
|
|
|
+ default: () => ({}),
|
|
|
+ },
|
|
|
+ position: {
|
|
|
+ type: Array as () => [number, number, number],
|
|
|
+ default: () => [0, 5, -10],
|
|
|
+ },
|
|
|
+ scale: {
|
|
|
+ type: Number,
|
|
|
+ default: 0.5,
|
|
|
+ },
|
|
|
+ threeScene: {
|
|
|
+ type: Object as () => Scene,
|
|
|
+ default: null,
|
|
|
+ },
|
|
|
+ instanceId: {
|
|
|
+ type: String,
|
|
|
+ default: '',
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ const panelRef = ref<HTMLElement | null>(null);
|
|
|
+ const cssObject = ref<CSS3DObject | null>(null);
|
|
|
+
|
|
|
+ // 默认阈值
|
|
|
+ const defaultThreshold: Required<AlarmThreshold> = {
|
|
|
+ microseism: 100,
|
|
|
+ hcl: 100,
|
|
|
+ fiberTemp: 100,
|
|
|
+ co: 10,
|
|
|
+ temperature: 50,
|
|
|
+ smoke: 30,
|
|
|
+ flame: 1,
|
|
|
+ multiParam: 100,
|
|
|
+ aiFlame: 1,
|
|
|
+ };
|
|
|
+
|
|
|
+ // 合并阈值
|
|
|
+ const currentThreshold = computed(() => ({
|
|
|
+ ...defaultThreshold,
|
|
|
+ ...props.threshold,
|
|
|
+ }));
|
|
|
+
|
|
|
+ // 判断状态:normal/warn/alarm
|
|
|
+ const getStatusClass = (key: keyof AlarmThreshold) => {
|
|
|
+ const value = props.sensorData[key];
|
|
|
+ if (value == null) return 'normal';
|
|
|
+ const threshold = currentThreshold.value[key]!;
|
|
|
+ if (value > threshold) return 'alarm';
|
|
|
+ return 'normal';
|
|
|
+ };
|
|
|
+
|
|
|
+ // 全局报警状态(任意项异常则面板变红)
|
|
|
+ const hasAlarm = computed(() => {
|
|
|
+ return Object.keys(currentThreshold.value).some((key) => {
|
|
|
+ const value = props.sensorData[key as keyof AlarmThreshold];
|
|
|
+ return value != null && value > currentThreshold.value[key as keyof AlarmThreshold]!;
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ // 面朝相机更新
|
|
|
+ const update = (camera: Camera) => {
|
|
|
+ if (cssObject.value) {
|
|
|
+ cssObject.value.lookAt(camera.position);
|
|
|
+ // 修复翻转
|
|
|
+ cssObject.value.rotation.z = Math.PI;
|
|
|
+ cssObject.value.rotation.x = Math.PI;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ onMounted(() => {
|
|
|
+ if (!panelRef.value) return;
|
|
|
+ cssObject.value = new CSS3DObject(panelRef.value);
|
|
|
+ cssObject.value.position.set(props.position[0], props.position[1] + 2, props.position[2] + 3.4);
|
|
|
+ cssObject.value.rotation.z += Math.PI;
|
|
|
+ cssObject.value.rotation.x += Math.PI;
|
|
|
+ cssObject.value.scale.setScalar(props.scale);
|
|
|
+ cssObject.value.lookAt(props.position[0], props.position[1] + 30, props.position[2] - 10);
|
|
|
+ if (props.threeScene) {
|
|
|
+ props.threeScene.add(cssObject.value);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 销毁方法
|
|
|
+ const destroy = () => {
|
|
|
+ if (cssObject.value && props.threeScene) {
|
|
|
+ props.threeScene.remove(cssObject.value);
|
|
|
+ (cssObject.value as any).element = null;
|
|
|
+ cssObject.value = null;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ onUnmounted(() => {
|
|
|
+ destroy();
|
|
|
+ });
|
|
|
+
|
|
|
+ // 暴露方法
|
|
|
+ defineExpose({
|
|
|
+ id: props.instanceId,
|
|
|
+ cssObject: cssObject,
|
|
|
+ update,
|
|
|
+ destroy,
|
|
|
+ });
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+ /* ====================================== */
|
|
|
+ /* 🔥 核心:从中间向两边扩散展开动画 */
|
|
|
+ /* ====================================== */
|
|
|
+ .panel-wrapper {
|
|
|
+ animation: panelExpand 1.7s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 扩散动画:中间 → 左右展开 */
|
|
|
+ @keyframes panelExpand {
|
|
|
+ 0% {
|
|
|
+ opacity: 0;
|
|
|
+ transform: scaleX(0); /* 中间闭合 */
|
|
|
+ }
|
|
|
+ 100% {
|
|
|
+ opacity: 1;
|
|
|
+ transform: scaleX(1); /* 展开到完整宽度 */
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /* ====================================== */
|
|
|
+ /* 主面板(科技风 + 状态颜色) */
|
|
|
+ /* ====================================== */
|
|
|
+ .monitor-panel {
|
|
|
+ width: 760px;
|
|
|
+ background: linear-gradient(145deg, rgba(10, 30, 60, 0.95), rgba(15, 40, 80, 0.9));
|
|
|
+ border: 5px solid #40a9ff;
|
|
|
+ border-radius: 16px;
|
|
|
+ padding: 28px 24px 40px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ position: absolute;
|
|
|
+ font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
|
|
+ color: #fff;
|
|
|
+ box-shadow:
|
|
|
+ 0 12px 40px rgba(64, 169, 255, 0.25),
|
|
|
+ inset 0 0 0 1px rgba(64, 169, 255, 0.15);
|
|
|
+ backdrop-filter: blur(12px);
|
|
|
+ transition: all 0.4s ease;
|
|
|
+ overflow: visible;
|
|
|
+ transform-origin: center center; /* 扩散中心点:中间 */
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 报警状态整体变红 */
|
|
|
+ .monitor-panel.alarm {
|
|
|
+ border-color: #ff4d4f;
|
|
|
+ box-shadow:
|
|
|
+ 0 12px 40px rgba(255, 77, 79, 0.35),
|
|
|
+ inset 0 0 0 1px rgba(255, 77, 79, 0.2);
|
|
|
+ animation: panelPulse 1.5s infinite alternate;
|
|
|
+ }
|
|
|
+
|
|
|
+ @keyframes panelPulse {
|
|
|
+ 0% {
|
|
|
+ box-shadow:
|
|
|
+ 0 12px 40px rgba(255, 77, 79, 0.35),
|
|
|
+ inset 0 0 0 1px rgba(255, 77, 79, 0.2);
|
|
|
+ }
|
|
|
+ 100% {
|
|
|
+ box-shadow:
|
|
|
+ 0 12px 50px rgba(255, 77, 79, 0.5),
|
|
|
+ inset 0 0 0 1px rgba(255, 77, 79, 0.3);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 标题 */
|
|
|
+ .panel-title {
|
|
|
+ text-align: center;
|
|
|
+ font-size: 40px;
|
|
|
+ font-weight: 700;
|
|
|
+ margin-bottom: 24px;
|
|
|
+ letter-spacing: 4px;
|
|
|
+ background: linear-gradient(90deg, #40a9ff, #69c0ff);
|
|
|
+ -webkit-background-clip: text;
|
|
|
+ -webkit-text-fill-color: transparent;
|
|
|
+ text-shadow: 0 0 16px rgba(64, 169, 255, 0.4);
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 网格布局 */
|
|
|
+ .panel-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 1fr 1fr;
|
|
|
+ gap: 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .item-full {
|
|
|
+ grid-column: 1 / -1;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 监控项 */
|
|
|
+ .panel-item {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ background: rgba(30, 70, 120, 0.4);
|
|
|
+ border: 3px solid rgba(64, 169, 255, 0.3);
|
|
|
+ padding: 16px 20px;
|
|
|
+ border-radius: 12px;
|
|
|
+ font-size: 25px;
|
|
|
+ transition: all 0.3s ease;
|
|
|
+ cursor: default;
|
|
|
+ }
|
|
|
+
|
|
|
+ .panel-item:hover {
|
|
|
+ background: rgba(40, 90, 150, 0.5);
|
|
|
+ border-color: rgba(64, 169, 255, 0.6);
|
|
|
+ transform: translateY(-2px);
|
|
|
+ box-shadow: 0 4px 12px rgba(64, 169, 255, 0.2);
|
|
|
+ }
|
|
|
+
|
|
|
+ .item-label {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #e6f3ff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .ai-label {
|
|
|
+ color: #36cffb;
|
|
|
+ text-shadow: 0 0 8px rgba(54, 207, 251, 0.3);
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 数值正常颜色 */
|
|
|
+ .item-value {
|
|
|
+ font-weight: 700;
|
|
|
+ font-size: 27px;
|
|
|
+ color: #40a9ff;
|
|
|
+ letter-spacing: 2px;
|
|
|
+ transition: all 0.3s ease;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 报警闪烁 */
|
|
|
+ .item-value.alarm {
|
|
|
+ color: #ff4d4f !important;
|
|
|
+ text-shadow: 0 0 12px rgba(255, 77, 79, 0.6);
|
|
|
+ animation: valueBlink 0.8s infinite alternate;
|
|
|
+ }
|
|
|
+
|
|
|
+ @keyframes valueBlink {
|
|
|
+ 0% {
|
|
|
+ opacity: 0.6;
|
|
|
+ transform: scale(1);
|
|
|
+ }
|
|
|
+ 100% {
|
|
|
+ opacity: 1;
|
|
|
+ transform: scale(1.05);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 底部定位箭头 */
|
|
|
+ .panel-arrow {
|
|
|
+ position: absolute;
|
|
|
+ bottom: -18px;
|
|
|
+ left: 50%;
|
|
|
+ transform: translateX(-50%);
|
|
|
+ width: 0;
|
|
|
+ height: 0;
|
|
|
+ border-left: 18px solid transparent;
|
|
|
+ border-right: 18px solid transparent;
|
|
|
+ border-top: 18px solid #40a9ff;
|
|
|
+ filter: drop-shadow(0 4px 8px rgba(64, 169, 255, 0.3));
|
|
|
+ transition: all 0.4s ease;
|
|
|
+ }
|
|
|
+
|
|
|
+ .monitor-panel.alarm .panel-arrow {
|
|
|
+ border-top-color: #ff4d4f;
|
|
|
+ filter: drop-shadow(0 4px 8px rgba(255, 77, 79, 0.4));
|
|
|
+ }
|
|
|
+</style>
|