MonitorPanel.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. <!-- MonitorPanel.vue 完整版(支持销毁) -->
  2. <template>
  3. <!-- 外层动画包裹层 -->
  4. <div class="panel-wrapper">
  5. <!-- 'monitor-panel-zzg': sensorData.positionName == '总支管#', -->
  6. <div class="monitor-panel" :class="{ alarm: hasAlarm }" ref="panelRef">
  7. <!-- <div style="position: absolute; top: -80px; right: 20px; font-size: 50px; width: 100%; text-align: center">总支管</div> -->
  8. <div>
  9. <div class="title"><div class="line"></div>{{ monitorData.positionName }}旋进漩涡流量计<div class="line"></div></div>
  10. <div class="panel-grid">
  11. <div class="panel-item">
  12. <span class="item-label">▸瞬时标况流量 </span>
  13. <div>
  14. <span class="item-value" :class="getStatusClass('flowStdInstant')">
  15. {{ monitorData.flowStdInstant ?? 'XXXX' }}
  16. </span>
  17. <span>Nm3/h</span>
  18. </div>
  19. </div>
  20. <div class="panel-item">
  21. <span class="item-label">▸ 压力</span>
  22. <div>
  23. <span class="item-value" :class="getStatusClass('injectionPressure')">
  24. {{ monitorData.injectionPressure ?? 'XXXX' }}
  25. </span>
  26. <span>MPa</span>
  27. </div>
  28. </div>
  29. <div class="panel-item">
  30. <span class="item-label">▸ 温度 </span>
  31. <div>
  32. <span class="item-value" :class="getStatusClass('injectionTemperature')">
  33. {{ monitorData.injectionTemperature ?? 'XXXX' }}
  34. </span>
  35. <span>℃</span>
  36. </div>
  37. </div>
  38. <div class="panel-item">
  39. <span class="item-label">▸ 累计注气量</span>
  40. <div>
  41. <span class="item-value" :class="getStatusClass('flowStdAccum')">
  42. {{ monitorData.flowStdAccum ?? 'XXXX' }}
  43. </span>
  44. <span>Nm3</span>
  45. </div>
  46. </div>
  47. </div>
  48. </div>
  49. <div>
  50. <div class="title"><div class="line"></div>{{ monitorData.positionName }}阀门<div class="line"></div></div>
  51. <div class="panel-grid">
  52. <div class="panel-item">
  53. <span class="item-label">▸ 阀门开度 </span>
  54. <div>
  55. <span class="item-value" :class="getStatusClass('valveOpening')">
  56. {{ monitorData.valveOpening ?? 'XXXX' }}
  57. </span>
  58. <span>°</span>
  59. </div>
  60. </div>
  61. </div>
  62. </div>
  63. <div :class="{ 'panel-arrow': monitorData.positionName !== '总支管#', 'panel-line': monitorData.positionName == '总支管#' }"></div>
  64. </div>
  65. </div>
  66. </template>
  67. <script setup lang="ts">
  68. import { ref, onMounted, computed, onUnmounted, Ref, watch } from 'vue';
  69. import { CSS3DObject } from 'three/examples/jsm/renderers/CSS3DRenderer.js';
  70. import type { Scene, Camera } from 'three';
  71. export interface AlarmThreshold {
  72. flowStdInstant?: number;
  73. flowStdAccum?: number;
  74. injectionTemperature?: number;
  75. injectionPressure?: number;
  76. valveOpening?: number;
  77. paiqiStatus?: number;
  78. }
  79. export interface SensorData {
  80. positionName?: string;
  81. flowStdInstant?: number;
  82. flowStdAccum?: number;
  83. injectionTemperature?: number;
  84. injectionPressure?: number;
  85. valveOpening?: number;
  86. paiqiStatus?: number;
  87. }
  88. const props = defineProps({
  89. sensorData: {
  90. type: Object as () => Ref<SensorData>,
  91. default: () => ({}),
  92. },
  93. threshold: {
  94. type: Object as () => AlarmThreshold,
  95. default: () => ({}),
  96. },
  97. position: {
  98. type: Array as () => [number, number, number],
  99. default: () => [0, 5, -10],
  100. },
  101. scale: {
  102. type: Number,
  103. default: 0.5,
  104. },
  105. threeScene: {
  106. type: Object as () => Scene,
  107. default: null,
  108. },
  109. instanceId: {
  110. type: String,
  111. default: '',
  112. },
  113. });
  114. const panelRef = ref<HTMLElement | null>(null);
  115. const cssObject = ref<CSS3DObject | null>(null);
  116. const monitorData = ref<SensorData>({});
  117. watch(
  118. () => props.sensorData.value,
  119. (newValue, oldValue) => {
  120. if (newValue) monitorData.value = newValue;
  121. },
  122. { immediate: true }
  123. );
  124. // 默认阈值
  125. const defaultThreshold: Required<AlarmThreshold> = {
  126. flowStdInstant: 100,
  127. flowStdAccum: 100,
  128. injectionTemperature: 100,
  129. injectionPressure: 10,
  130. valveOpening: 50,
  131. paiqiStatus: 30,
  132. };
  133. // 合并阈值
  134. const currentThreshold = computed(() => ({
  135. ...defaultThreshold,
  136. ...props.threshold,
  137. }));
  138. // ✅ 正确:访问 .value
  139. const getStatusClass = (key: keyof AlarmThreshold) => {
  140. // 1. 先获取当前数据对象
  141. const currentData = props.sensorData.value;
  142. // 2. 安全访问属性
  143. const value = currentData ? currentData[key] : undefined;
  144. if (value == null) return 'normal';
  145. const threshold = currentThreshold.value[key]!;
  146. if (value > threshold) {
  147. return 'alarm';
  148. }
  149. return 'normal';
  150. };
  151. // ✅ 正确:hasAlarm 也要修改
  152. const hasAlarm = computed(() => {
  153. const currentData = props.sensorData.value; // 👈 获取值
  154. if (!currentData) return false;
  155. return Object.keys(currentThreshold.value).some((key) => {
  156. const value = currentData[key as keyof AlarmThreshold];
  157. return value != null && value > currentThreshold.value[key as keyof AlarmThreshold]!;
  158. });
  159. });
  160. // 面朝相机更新
  161. const update = (camera: Camera) => {
  162. if (cssObject.value) {
  163. // cssObject.value.lookAt(camera.position);
  164. // 修复翻转
  165. // cssObject.value.rotation.z = Math.PI;
  166. // cssObject.value.rotation.x = Math.PI;
  167. }
  168. };
  169. onMounted(() => {
  170. if (!panelRef.value) return;
  171. cssObject.value = new CSS3DObject(panelRef.value);
  172. cssObject.value.scale.set(props.scale, props.scale, props.scale);
  173. // cssObject.value.scale.set(0, 0, 0);
  174. cssObject.value.position.set(props.position[0], props.position[1] + 1.56, props.position[2]);
  175. // cssObject.value.center.set(0, 0);
  176. cssObject.value.rotation.y -= Math.PI / 2;
  177. if (props.threeScene) {
  178. props.threeScene.add(cssObject.value);
  179. }
  180. });
  181. // 销毁方法
  182. const destroy = () => {
  183. if (cssObject.value && props.threeScene) {
  184. props.threeScene.remove(cssObject.value);
  185. (cssObject.value as any).element = null;
  186. cssObject.value = null;
  187. }
  188. };
  189. onUnmounted(() => {
  190. destroy();
  191. });
  192. // 暴露方法
  193. defineExpose({
  194. id: props.instanceId,
  195. cssObject: cssObject,
  196. update,
  197. destroy,
  198. });
  199. </script>
  200. <style scoped lang="less">
  201. /* ====================================== */
  202. /* 🔥 核心:从中间向两边扩散展开动画 */
  203. /* ====================================== */
  204. .panel-wrapper {
  205. animation: panelExpand 1.7s cubic-bezier(0.22, 1, 0.36, 1) forwards;
  206. }
  207. /* 扩散动画:中间 → 左右展开 */
  208. @keyframes panelExpand {
  209. 0% {
  210. opacity: 0;
  211. transform: scaleX(0); /* 中间闭合 */
  212. }
  213. 100% {
  214. opacity: 1;
  215. transform: scaleX(1); /* 展开到完整宽度 */
  216. }
  217. }
  218. /* ====================================== */
  219. /* 主面板(科技风 + 状态颜色) */
  220. /* ====================================== */
  221. .monitor-panel {
  222. width: 1000px;
  223. background: linear-gradient(145deg, rgba(10, 42, 60, 0.55), rgba(15, 40, 80, 0.9));
  224. border: 5px solid #40d9ff;
  225. border-radius: 16px;
  226. padding: 20px 24px 40px;
  227. box-sizing: border-box;
  228. position: absolute;
  229. font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif;
  230. color: #fff;
  231. box-shadow:
  232. 0 12px 40px rgba(64, 169, 255, 0.25),
  233. inset 0 0 0 1px rgba(64, 169, 255, 0.15);
  234. backdrop-filter: blur(12px);
  235. transition: all 0.4s ease;
  236. overflow: visible;
  237. transform-origin: center center; /* 扩散中心点:中间 */
  238. }
  239. .monitor-panel-zzg {
  240. background: linear-gradient(145deg, rgba(10, 50, 60, 0.55), rgba(15, 56, 80, 0.9));
  241. border: 5px solid #9b9b9b;
  242. }
  243. /* 报警状态整体变红 */
  244. .monitor-panel .alarm {
  245. border-color: #ff4d4f;
  246. box-shadow:
  247. 0 20px 40px rgba(255, 77, 79, 0.35),
  248. inset 0 0 6px 2px rgba(255, 77, 79, 0.2);
  249. animation: panelPulse 1.5s infinite alternate;
  250. }
  251. @keyframes panelPulse {
  252. 0% {
  253. box-shadow:
  254. 0 20px 40px rgba(255, 77, 79, 0.35),
  255. inset 0 0 6px 2px rgba(255, 77, 79, 0.2);
  256. }
  257. 100% {
  258. box-shadow:
  259. 0 20px 50px rgba(255, 77, 79, 0.5),
  260. inset 0 0 6px 2px rgba(255, 77, 79, 0.3);
  261. }
  262. }
  263. /* 标题 */
  264. .panel-title {
  265. text-align: center;
  266. font-size: 40px;
  267. font-weight: 700;
  268. margin-bottom: 24px;
  269. letter-spacing: 4px;
  270. background: linear-gradient(90deg, #40a9ff, #69c0ff);
  271. -webkit-background-clip: text;
  272. -webkit-text-fill-color: transparent;
  273. text-shadow: 0 0 16px rgba(64, 169, 255, 0.4);
  274. position: absolute;
  275. }
  276. /* 网格布局 */
  277. .panel-grid {
  278. display: grid;
  279. grid-template-columns: 1fr 1fr;
  280. gap: 16px;
  281. }
  282. .title {
  283. width: 100%;
  284. font-size: 35px;
  285. text-align: center;
  286. padding: 20px 0;
  287. display: flex;
  288. flex-direction: row;
  289. justify-content: center;
  290. align-items: center;
  291. justify-content: space-between;
  292. .line {
  293. flex: 1;
  294. height: 1.2px;
  295. background-color: #36cffb;
  296. }
  297. }
  298. .item-full {
  299. grid-column: 1 / -1;
  300. }
  301. /* 监控项 */
  302. .panel-item {
  303. display: flex;
  304. justify-content: space-between;
  305. align-items: center;
  306. background: rgba(30, 70, 120, 0.4);
  307. border: 3px solid rgba(64, 169, 255, 0.3);
  308. padding: 12px 10px;
  309. border-radius: 12px;
  310. font-size: 28px;
  311. transition: all 0.3s ease;
  312. cursor: default;
  313. }
  314. .panel-item:hover {
  315. background: rgba(40, 90, 150, 0.5);
  316. border-color: rgba(64, 169, 255, 0.6);
  317. transform: translateY(-2px);
  318. box-shadow: 0 4px 12px rgba(64, 169, 255, 0.2);
  319. }
  320. .item-label {
  321. display: flex;
  322. align-items: center;
  323. gap: 10px;
  324. font-weight: 500;
  325. color: #e6f3ff;
  326. }
  327. .ai-label {
  328. color: #36cffb;
  329. text-shadow: 0 0 8px rgba(54, 207, 251, 0.3);
  330. }
  331. /* 数值正常颜色 */
  332. .item-value {
  333. font-weight: 700;
  334. font-size: 27px;
  335. color: #40a9ff;
  336. letter-spacing: 2px;
  337. transition: all 0.3s ease;
  338. padding-right: 5px;
  339. }
  340. /* 报警闪烁 */
  341. .item-value .alarm {
  342. color: #ff4d4f !important;
  343. text-shadow: 0 0 12px rgba(255, 77, 79, 0.6);
  344. animation: valueBlink 0.8s infinite alternate;
  345. }
  346. @keyframes valueBlink {
  347. 0% {
  348. opacity: 0.6;
  349. transform: scale(1);
  350. }
  351. 100% {
  352. opacity: 1;
  353. transform: scale(1.05);
  354. }
  355. }
  356. /* 底部定位箭头 */
  357. .panel-arrow {
  358. position: absolute;
  359. bottom: -18px;
  360. left: 50%;
  361. transform: translateX(-50%);
  362. width: 0;
  363. height: 0;
  364. border-left: 18px solid transparent;
  365. border-right: 18px solid transparent;
  366. border-top: 18px solid #1eddff;
  367. filter: drop-shadow(0 4px 8px rgba(64, 169, 255, 0.3));
  368. transition: all 0.4s ease;
  369. }
  370. .panel-line {
  371. position: absolute;
  372. bottom: -180px;
  373. left: -180px;
  374. // transform: translateX(-50%);
  375. width: 180px;
  376. height: 8px;
  377. background: #7f8f9c;
  378. border-left: 2px solid #00aaff; /* 斜线颜色粗细 */
  379. border-bottom: 2px solid #00aaff;
  380. transform: skewY(-45deg); /* 角度 */
  381. transform-origin: left bottom; /* 从左下角开始 */
  382. // border-left: 18px solid transparent;
  383. // border-right: 18px solid transparent;
  384. // border-top: 18px solid #40a9ff;
  385. // filter: drop-shadow(0 4px 8px rgba(64, 169, 255, 0.3));
  386. // transition: all 0.4s ease;
  387. }
  388. .monitor-panel .alarm .panel-arrow {
  389. border-top-color: #ff4d4f;
  390. filter: drop-shadow(0 8px 16px rgba(255, 77, 79, 0.4));
  391. }
  392. </style>