Переглянути джерело

提交模型监测组件文件

hongrunxia 3 тижнів тому
батько
коміт
191dd88b88

+ 433 - 0
src/views/vent/home/configurable/threejs/MonitorPanel.vue

@@ -0,0 +1,433 @@
+<!-- MonitorPanel.vue 完整版(支持销毁) -->
+<template>
+  <!-- 外层动画包裹层 -->
+
+  <div class="panel-wrapper">
+    <!-- 'monitor-panel-zzg': sensorData.positionName == '总支管#', -->
+    <div class="monitor-panel" :class="{ alarm: hasAlarm }" ref="panelRef">
+      <!-- <div style="position: absolute; top: -80px; right: 20px; font-size: 50px; width: 100%; text-align: center">总支管</div> -->
+      <div>
+        <div class="title"><div class="line"></div>{{ monitorData.positionName }}旋进漩涡流量计<div class="line"></div></div>
+        <div class="panel-grid">
+          <div class="panel-item">
+            <span class="item-label">▸瞬时标况流量 </span>
+            <div>
+              <span class="item-value" :class="getStatusClass('flowStdInstant')">
+                {{ monitorData.flowStdInstant ?? 'XXXX' }}
+              </span>
+              <span>Nm3/h</span>
+            </div>
+          </div>
+          <div class="panel-item">
+            <span class="item-label">▸ 压力</span>
+            <div>
+              <span class="item-value" :class="getStatusClass('injectionPressure')">
+                {{ monitorData.injectionPressure ?? 'XXXX' }}
+              </span>
+              <span>MPa</span>
+            </div>
+          </div>
+          <div class="panel-item">
+            <span class="item-label">▸ 温度 </span>
+            <div>
+              <span class="item-value" :class="getStatusClass('injectionTemperature')">
+                {{ monitorData.injectionTemperature ?? 'XXXX' }}
+              </span>
+              <span>℃</span>
+            </div>
+          </div>
+          <div class="panel-item">
+            <span class="item-label">▸ 累计注气量</span>
+            <div>
+              <span class="item-value" :class="getStatusClass('flowStdAccum')">
+                {{ monitorData.flowStdAccum ?? 'XXXX' }}
+              </span>
+              <span>Nm3</span>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div>
+        <div class="title"><div class="line"></div>{{ monitorData.positionName }}阀门<div class="line"></div></div>
+        <div class="panel-grid">
+          <div class="panel-item">
+            <span class="item-label">▸ 阀门开度 </span>
+            <div>
+              <span class="item-value" :class="getStatusClass('valveOpening')">
+                {{ monitorData.valveOpening ?? 'XXXX' }}
+              </span>
+              <span>°</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div :class="{ 'panel-arrow': monitorData.positionName !== '总支管#', 'panel-line': monitorData.positionName == '总支管#' }"></div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, onMounted, computed, onUnmounted, Ref, watch } from 'vue';
+  import { CSS3DObject } from 'three/examples/jsm/renderers/CSS3DRenderer.js';
+  import type { Scene, Camera } from 'three';
+
+  export interface AlarmThreshold {
+    flowStdInstant?: number;
+    flowStdAccum?: number;
+    injectionTemperature?: number;
+    injectionPressure?: number;
+    valveOpening?: number;
+    paiqiStatus?: number;
+  }
+
+  export interface SensorData {
+    positionName?: string;
+    flowStdInstant?: number;
+    flowStdAccum?: number;
+    injectionTemperature?: number;
+    injectionPressure?: number;
+    valveOpening?: number;
+    paiqiStatus?: number;
+  }
+
+  const props = defineProps({
+    sensorData: {
+      type: Object as () => Ref<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 monitorData = ref<SensorData>({});
+
+  watch(
+    () => props.sensorData.value,
+    (newValue, oldValue) => {
+      if (newValue) monitorData.value = newValue;
+    },
+    { immediate: true }
+  );
+
+  // 默认阈值
+  const defaultThreshold: Required<AlarmThreshold> = {
+    flowStdInstant: 100,
+    flowStdAccum: 100,
+    injectionTemperature: 100,
+    injectionPressure: 10,
+    valveOpening: 50,
+    paiqiStatus: 30,
+  };
+
+  // 合并阈值
+  const currentThreshold = computed(() => ({
+    ...defaultThreshold,
+    ...props.threshold,
+  }));
+
+  // ✅ 正确:访问 .value
+  const getStatusClass = (key: keyof AlarmThreshold) => {
+    // 1. 先获取当前数据对象
+    const currentData = props.sensorData.value;
+
+    // 2. 安全访问属性
+    const value = currentData ? currentData[key] : undefined;
+
+    if (value == null) return 'normal';
+
+    const threshold = currentThreshold.value[key]!;
+    if (value > threshold) {
+      return 'alarm';
+    }
+    return 'normal';
+  };
+
+  // ✅ 正确:hasAlarm 也要修改
+  const hasAlarm = computed(() => {
+    const currentData = props.sensorData.value; // 👈 获取值
+    if (!currentData) return false;
+
+    return Object.keys(currentThreshold.value).some((key) => {
+      const value = currentData[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.scale.set(props.scale, props.scale, props.scale);
+    // cssObject.value.scale.set(0, 0, 0);
+    cssObject.value.position.set(props.position[0], props.position[1] + 1.56, props.position[2]);
+    // cssObject.value.center.set(0, 0);
+    cssObject.value.rotation.y -= Math.PI / 2;
+
+    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 lang="less">
+  /* ====================================== */
+  /* 🔥 核心:从中间向两边扩散展开动画 */
+  /* ====================================== */
+  .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: 1000px;
+    background: linear-gradient(145deg, rgba(10, 42, 60, 0.55), rgba(15, 40, 80, 0.9));
+    border: 5px solid #40d9ff;
+
+    border-radius: 16px;
+    padding: 20px 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-zzg {
+    background: linear-gradient(145deg, rgba(10, 50, 60, 0.55), rgba(15, 56, 80, 0.9));
+    border: 5px solid #9b9b9b;
+  }
+
+  /* 报警状态整体变红 */
+  .monitor-panel .alarm {
+    border-color: #ff4d4f;
+
+    box-shadow:
+      0 20px 40px rgba(255, 77, 79, 0.35),
+      inset 0 0 6px 2px rgba(255, 77, 79, 0.2);
+    animation: panelPulse 1.5s infinite alternate;
+  }
+
+  @keyframes panelPulse {
+    0% {
+      box-shadow:
+        0 20px 40px rgba(255, 77, 79, 0.35),
+        inset 0 0 6px 2px rgba(255, 77, 79, 0.2);
+    }
+    100% {
+      box-shadow:
+        0 20px 50px rgba(255, 77, 79, 0.5),
+        inset 0 0 6px 2px 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);
+    position: absolute;
+  }
+
+  /* 网格布局 */
+  .panel-grid {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 16px;
+  }
+  .title {
+    width: 100%;
+    font-size: 35px;
+    text-align: center;
+    padding: 20px 0;
+    display: flex;
+    flex-direction: row;
+    justify-content: center;
+    align-items: center;
+    justify-content: space-between;
+    .line {
+      flex: 1;
+      height: 1.2px;
+      background-color: #36cffb;
+    }
+  }
+  .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: 12px 10px;
+    border-radius: 12px;
+    font-size: 28px;
+    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;
+    padding-right: 5px;
+  }
+
+  /* 报警闪烁 */
+  .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 #1eddff;
+    filter: drop-shadow(0 4px 8px rgba(64, 169, 255, 0.3));
+    transition: all 0.4s ease;
+  }
+
+  .panel-line {
+    position: absolute;
+    bottom: -180px;
+    left: -180px;
+    // transform: translateX(-50%);
+    width: 180px;
+    height: 8px;
+    background: #7f8f9c;
+    border-left: 2px solid #00aaff; /* 斜线颜色粗细 */
+    border-bottom: 2px solid #00aaff;
+    transform: skewY(-45deg); /* 角度 */
+    transform-origin: left bottom; /* 从左下角开始 */
+    // 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 8px 16px rgba(255, 77, 79, 0.4));
+  }
+</style>

+ 101 - 0
src/views/vent/home/configurable/threejs/PanelManager.ts

@@ -0,0 +1,101 @@
+// PanelManager.ts
+import { createApp, App, Ref } from 'vue';
+import { Scene } from 'three';
+import MonitorPanel, { SensorData, AlarmThreshold } from './MonitorPanel.vue';
+
+// 面板实例结构
+export interface PanelInstance {
+  id: string;
+  app: App;
+  vm: any; // Vue 实例
+  threeScene: Scene;
+}
+
+class PanelManager {
+  private panels: Map<string, PanelInstance> = new Map();
+
+  /**
+   * 创建并添加一个监控面板到 Three.js 场景
+   */
+  createPanel(
+    threeScene: Scene,
+    options: {
+      instanceId: string;
+      sensorData: Ref<SensorData>;
+      threshold?: AlarmThreshold;
+      position?: [number, number, number];
+      scale?: number;
+    }
+  ): PanelInstance {
+    const root = document.createElement('div');
+    // 关键:给容器加 ID(方便调试)
+    const panelId = options.instanceId;
+    root.id = panelId;
+    document.body.appendChild(root);
+    // 创建 Vue 应用
+    const app = createApp(MonitorPanel, {
+      instanceId: options.instanceId,
+      sensorData: options.sensorData,
+      threshold: options.threshold,
+      position: options.position,
+      scale: options.scale,
+      threeScene, // 传入场景,方便组件内部销毁
+    });
+
+    const vm = app.mount(root);
+
+    const instance: PanelInstance = {
+      id: vm.id, // 来自组件内 expose 的 id
+      app,
+      vm,
+      threeScene,
+    };
+
+    this.panels.set(vm.id, instance);
+    return instance;
+  }
+
+  /**
+   * 根据 ID 获取面板实例
+   */
+  getPanel(id: string): PanelInstance | undefined {
+    return this.panels.get(id);
+  }
+
+  /**
+   * 销毁指定 ID 的面板
+   */
+  destroyPanel(id: string): boolean {
+    const instance = this.panels.get(id);
+    if (!instance) return false;
+
+    // 1. 调用组件内部销毁(清理 Three.js)
+    instance.vm.destroy?.();
+    // 2. 卸载 Vue 实例
+    instance.app.unmount();
+    // 3. 移除 DOM
+    const el = document.getElementById(id);
+    if (el) el.remove();
+
+    // 4. 从管理器删除
+    this.panels.delete(id);
+    return true;
+  }
+
+  /**
+   * 销毁所有面板
+   */
+  destroyAll(): void {
+    this.panels.forEach((_, id) => this.destroyPanel(id));
+  }
+
+  /**
+   * 获取所有面板 ID 列表
+   */
+  getAllPanelIds(): string[] {
+    return Array.from(this.panels.keys());
+  }
+}
+
+// 单例导出
+export const panelManager = new PanelManager();