Explorar el Código

✨ feat: 连油监控

Zimo hace 3 días
padre
commit
e13c61a761

+ 1 - 0
package.json

@@ -34,6 +34,7 @@
     "@fullcalendar/vue3": "^6.1.17",
     "@iconify/iconify": "^3.1.1",
     "@microsoft/fetch-event-source": "^2.0.1",
+    "@number-flow/vue": "^0.4.8",
     "@types/echarts": "^5.0.0",
     "@videojs-player/vue": "^1.0.0",
     "@vueuse/core": "^10.9.0",

+ 26 - 198
pnpm-lock.yaml

@@ -35,6 +35,9 @@ importers:
       '@microsoft/fetch-event-source':
         specifier: ^2.0.1
         version: 2.0.1
+      '@number-flow/vue':
+        specifier: ^0.4.8
+        version: 0.4.8(vue@3.5.12(typescript@5.3.3))
       '@types/echarts':
         specifier: ^5.0.0
         version: 5.0.0
@@ -161,9 +164,6 @@ importers:
       qs:
         specifier: ^6.12.0
         version: 6.13.1
-      socket.io-client:
-        specifier: ^2.5.0
-        version: 2.5.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)
       sortablejs:
         specifier: 1.15.0
         version: 1.15.0
@@ -1439,6 +1439,11 @@ packages:
     resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
     engines: {node: '>= 8'}
 
+  '@number-flow/vue@0.4.8':
+    resolution: {integrity: sha512-g1kh66wndJ4MYkX5Z00GvyLxdOMbRotiTeTQujzb1XS009dL/mVH0ZRI5slXH1mzpl78yzzsjz3vjzGhTMM3CA==}
+    peerDependencies:
+      vue: ^3
+
   '@parcel/watcher-android-arm64@2.5.0':
     resolution: {integrity: sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==}
     engines: {node: '>= 10.0.0'}
@@ -2408,9 +2413,6 @@ packages:
   aes-decrypter@3.1.3:
     resolution: {integrity: sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A==}
 
-  after@0.8.2:
-    resolution: {integrity: sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA==}
-
   ajv@6.12.6:
     resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
 
@@ -2473,9 +2475,6 @@ packages:
     resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
     engines: {node: '>=8'}
 
-  arraybuffer.slice@0.0.7:
-    resolution: {integrity: sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==}
-
   astral-regex@2.0.0:
     resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
     engines: {node: '>=8'}
@@ -2520,19 +2519,12 @@ packages:
     peerDependencies:
       '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
 
-  backo2@1.0.2:
-    resolution: {integrity: sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==}
-
   balanced-match@1.0.2:
     resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
 
   balanced-match@2.0.0:
     resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==}
 
-  base64-arraybuffer@0.1.4:
-    resolution: {integrity: sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg==}
-    engines: {node: '>= 0.6.0'}
-
   benz-amr-recorder@1.1.5:
     resolution: {integrity: sha512-NepctcNTsZHK8NxBb5uKO5p8S+xkbm+vD6GLSkCYdJeEsriexvgumLHpDkanX4QJBcLRMVtg16buWMs+gUPB3g==}
 
@@ -2543,9 +2535,6 @@ packages:
     resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
     engines: {node: '>=8'}
 
-  blob@0.0.5:
-    resolution: {integrity: sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==}
-
   boolbase@1.0.0:
     resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
 
@@ -2595,10 +2584,6 @@ packages:
   buffer-from@1.1.2:
     resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
 
-  bufferutil@4.1.0:
-    resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==}
-    engines: {node: '>=6.14.2'}
-
   cac@6.7.14:
     resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
     engines: {node: '>=8'}
@@ -2724,18 +2709,9 @@ packages:
   compare-func@2.0.0:
     resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==}
 
-  component-bind@1.0.0:
-    resolution: {integrity: sha512-WZveuKPeKAG9qY+FkYDeADzdHyTYdIboXS59ixDeRJL5ZhxpqUnxSOwop4FQjMsiYm3/Or8cegVbpAHNA7pHxw==}
-
-  component-emitter@1.3.1:
-    resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
-
   component-event@0.2.1:
     resolution: {integrity: sha512-wGA++isMqiDq1jPYeyv2as/Bt/u+3iLW0rEa+8NQ82jAv3TgqMiCM+B2SaBdn2DfLilLjjq736YcezihRYhfxw==}
 
-  component-inherit@0.0.3:
-    resolution: {integrity: sha512-w+LhYREhatpVqTESyGFg3NlP6Iu0kEKUHETY9GoZP/pQyW4mHFZuFWRUCIqVPZ36ueVLtoOEZaAqbCF2RDndaA==}
-
   compute-scroll-into-view@1.0.20:
     resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
 
@@ -3154,12 +3130,6 @@ packages:
   emoji-regex@9.2.2:
     resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
 
-  engine.io-client@3.5.6:
-    resolution: {integrity: sha512-2fDMKiXSU7bGRDCWEw9cHEdRNfoU8cpP6lt+nwJhv72tSJpO7YBsqMqYZ63eVvwX3l9prPl2k/mxhfVhY+SDWg==}
-
-  engine.io-parser@2.2.1:
-    resolution: {integrity: sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg==}
-
   entities@4.5.0:
     resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
     engines: {node: '>=0.12'}
@@ -3274,6 +3244,9 @@ packages:
     deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
     hasBin: true
 
+  esm-env@1.2.2:
+    resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
+
   esniff@2.0.1:
     resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==}
     engines: {node: '>=0.10'}
@@ -3556,12 +3529,6 @@ packages:
     resolution: {integrity: sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==}
     engines: {node: '>=0.10.0'}
 
-  has-binary2@1.0.3:
-    resolution: {integrity: sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==}
-
-  has-cors@1.1.0:
-    resolution: {integrity: sha512-g5VNKdkFuUuVCP9gYfDJHjK2nqdQJ7aDLTnycnc2+RvsOQbuLdF5pm7vuE5J76SEBIQjs4kQY/BWq74JUmjbXA==}
-
   has-flag@4.0.0:
     resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
     engines: {node: '>=8'}
@@ -3648,9 +3615,6 @@ packages:
     resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
     engines: {node: '>=8'}
 
-  indexof@0.0.1:
-    resolution: {integrity: sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==}
-
   individual@2.0.0:
     resolution: {integrity: sha512-pWt8hBCqJsUWI/HtcfWod7+N9SgAqyPEaF7JQjwzjn5vGrpg6aQ5qeAFQ7dx//UH4J1O+7xqew+gCeeFt6xN/g==}
 
@@ -3739,9 +3703,6 @@ packages:
   is-url@1.2.4:
     resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
 
-  isarray@2.0.1:
-    resolution: {integrity: sha512-c2cu3UxbI+b6kR3fy0nRnAhodsvR9dx7U5+znCOzdj6IfP3upFURTr0Xl5BlQZNKZjEtxrmVyfSdeE3O57smoQ==}
-
   isexe@2.0.0:
     resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
 
@@ -4188,10 +4149,6 @@ packages:
       encoding:
         optional: true
 
-  node-gyp-build@4.8.4:
-    resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
-    hasBin: true
-
   node-html-parser@7.0.1:
     resolution: {integrity: sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==}
 
@@ -4222,6 +4179,9 @@ packages:
   nth-check@2.1.1:
     resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
 
+  number-flow@0.5.8:
+    resolution: {integrity: sha512-FPr1DumWyGi5Nucoug14bC6xEz70A1TnhgSHhKyfqjgji2SOTz+iLJxKtv37N5JyJbteGYCm6NQ9p1O4KZ7iiA==}
+
   object-inspect@1.13.3:
     resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
     engines: {node: '>= 0.4'}
@@ -4298,12 +4258,6 @@ packages:
   parse5@7.2.1:
     resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==}
 
-  parseqs@0.0.6:
-    resolution: {integrity: sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==}
-
-  parseuri@0.0.6:
-    resolution: {integrity: sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==}
-
   path-browserify@1.0.1:
     resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
 
@@ -4752,12 +4706,6 @@ packages:
     resolution: {integrity: sha512-ig5qOnCDbugFntKi6c7Xlib8bA6xiJVk8O+WdFrV3wxbMqeHO0hXFQC4nAhPVWfZfi8255lcZkNhtIBINCc4+Q==}
     engines: {node: '>=12.17.0'}
 
-  socket.io-client@2.5.0:
-    resolution: {integrity: sha512-lOO9clmdgssDykiOmVQQitwBAF3I6mYcQAo7hQ7AM6Ny5X7fp8hIJ3HcQs3Rjz4SoggoxA1OgrQyY8EgTbcPYw==}
-
-  socket.io-parser@3.3.4:
-    resolution: {integrity: sha512-z/pFQB3x+EZldRRzORYW1vwVO8m/3ILkswtnpoeU6Ve3cbMWkmHEWDAVJn4QJtchiiFTo5j7UG2QvwxvaA9vow==}
-
   sortablejs@1.14.0:
     resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
 
@@ -4933,9 +4881,6 @@ packages:
     resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==}
     engines: {node: '>=12.0.0'}
 
-  to-array@0.1.4:
-    resolution: {integrity: sha512-LhVdShQD/4Mk4zXNroIQZJC+Ap3zgLcDuwEdcmLv9CCO73NWockQDwyUnW/m8VX/EElfL6FcYx7EeutN4HJA6A==}
-
   to-regex-range@5.0.1:
     resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
     engines: {node: '>=8.0'}
@@ -5084,10 +5029,6 @@ packages:
     resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==}
     engines: {node: '>= 0.4'}
 
-  utf-8-validate@5.0.10:
-    resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==}
-    engines: {node: '>=6.14.2'}
-
   util-deprecate@1.0.2:
     resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
 
@@ -5332,18 +5273,6 @@ packages:
     resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
 
-  ws@7.5.10:
-    resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==}
-    engines: {node: '>=8.3.0'}
-    peerDependencies:
-      bufferutil: ^4.0.1
-      utf-8-validate: ^5.0.2
-    peerDependenciesMeta:
-      bufferutil:
-        optional: true
-      utf-8-validate:
-        optional: true
-
   xml-js@1.6.11:
     resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
     hasBin: true
@@ -5352,10 +5281,6 @@ packages:
     resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
     engines: {node: '>=12'}
 
-  xmlhttprequest-ssl@1.6.3:
-    resolution: {integrity: sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q==}
-    engines: {node: '>=0.4.0'}
-
   y18n@4.0.3:
     resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
 
@@ -5396,9 +5321,6 @@ packages:
     resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
     engines: {node: '>=12'}
 
-  yeast@0.1.2:
-    resolution: {integrity: sha512-8HFIh676uyGYP6wP13R/j6OJ/1HwJ46snpvzE7aHAN3Ryqh2yX6Xox2B4CUmTwwOIzlG3Bs7ocsP5dZH/R1Qbg==}
-
   yocto-queue@0.1.0:
     resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
     engines: {node: '>=10'}
@@ -6710,6 +6632,12 @@ snapshots:
       '@nodelib/fs.scandir': 2.1.5
       fastq: 1.17.1
 
+  '@number-flow/vue@0.4.8(vue@3.5.12(typescript@5.3.3))':
+    dependencies:
+      esm-env: 1.2.2
+      number-flow: 0.5.8
+      vue: 3.5.12(typescript@5.3.3)
+
   '@parcel/watcher-android-arm64@2.5.0':
     optional: true
 
@@ -7887,8 +7815,6 @@ snapshots:
       global: 4.4.0
       pkcs7: 1.0.4
 
-  after@0.8.2: {}
-
   ajv@6.12.6:
     dependencies:
       fast-deep-equal: 3.1.3
@@ -7942,8 +7868,6 @@ snapshots:
 
   array-union@2.1.0: {}
 
-  arraybuffer.slice@0.0.7: {}
-
   astral-regex@2.0.0: {}
 
   async-validator@4.2.5: {}
@@ -8004,14 +7928,10 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  backo2@1.0.2: {}
-
   balanced-match@1.0.2: {}
 
   balanced-match@2.0.0: {}
 
-  base64-arraybuffer@0.1.4: {}
-
   benz-amr-recorder@1.1.5:
     dependencies:
       benz-recorderjs: 1.0.5
@@ -8020,8 +7940,6 @@ snapshots:
 
   binary-extensions@2.3.0: {}
 
-  blob@0.0.5: {}
-
   boolbase@1.0.0: {}
 
   bootstrap-icons@1.12.1: {}
@@ -8089,11 +8007,6 @@ snapshots:
 
   buffer-from@1.1.2: {}
 
-  bufferutil@4.1.0:
-    dependencies:
-      node-gyp-build: 4.8.4
-    optional: true
-
   cac@6.7.14: {}
 
   call-bind@1.0.7:
@@ -8230,14 +8143,8 @@ snapshots:
       array-ify: 1.0.0
       dot-prop: 5.3.0
 
-  component-bind@1.0.0: {}
-
-  component-emitter@1.3.1: {}
-
   component-event@0.2.1: {}
 
-  component-inherit@0.0.3: {}
-
   compute-scroll-into-view@1.0.20: {}
 
   computeds@0.0.1: {}
@@ -8687,32 +8594,6 @@ snapshots:
 
   emoji-regex@9.2.2: {}
 
-  engine.io-client@3.5.6(bufferutil@4.1.0)(utf-8-validate@5.0.10):
-    dependencies:
-      component-emitter: 1.3.1
-      component-inherit: 0.0.3
-      debug: 3.1.0
-      engine.io-parser: 2.2.1
-      has-cors: 1.1.0
-      indexof: 0.0.1
-      parseqs: 0.0.6
-      parseuri: 0.0.6
-      ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)
-      xmlhttprequest-ssl: 1.6.3
-      yeast: 0.1.2
-    transitivePeerDependencies:
-      - bufferutil
-      - supports-color
-      - utf-8-validate
-
-  engine.io-parser@2.2.1:
-    dependencies:
-      after: 0.8.2
-      arraybuffer.slice: 0.0.7
-      base64-arraybuffer: 0.1.4
-      blob: 0.0.5
-      has-binary2: 1.0.3
-
   entities@4.5.0: {}
 
   env-paths@2.2.1: {}
@@ -8875,6 +8756,8 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  esm-env@1.2.2: {}
+
   esniff@2.0.1:
     dependencies:
       d: 1.0.2
@@ -9188,12 +9071,6 @@ snapshots:
     dependencies:
       ansi-regex: 2.1.1
 
-  has-binary2@1.0.3:
-    dependencies:
-      isarray: 2.0.1
-
-  has-cors@1.1.0: {}
-
   has-flag@4.0.0: {}
 
   has-property-descriptors@1.0.2:
@@ -9258,8 +9135,6 @@ snapshots:
 
   indent-string@4.0.0: {}
 
-  indexof@0.0.1: {}
-
   individual@2.0.0: {}
 
   inflight@1.0.6:
@@ -9321,8 +9196,6 @@ snapshots:
 
   is-url@1.2.4: {}
 
-  isarray@2.0.1: {}
-
   isexe@2.0.0: {}
 
   jackspeak@3.4.3:
@@ -9765,9 +9638,6 @@ snapshots:
     dependencies:
       whatwg-url: 5.0.0
 
-  node-gyp-build@4.8.4:
-    optional: true
-
   node-html-parser@7.0.1:
     dependencies:
       css-select: 5.1.0
@@ -9793,6 +9663,10 @@ snapshots:
     dependencies:
       boolbase: 1.0.0
 
+  number-flow@0.5.8:
+    dependencies:
+      esm-env: 1.2.2
+
   object-inspect@1.13.3: {}
 
   object-refs@0.3.0: {}
@@ -9876,10 +9750,6 @@ snapshots:
     dependencies:
       entities: 4.5.0
 
-  parseqs@0.0.6: {}
-
-  parseuri@0.0.6: {}
-
   path-browserify@1.0.1: {}
 
   path-exists@4.0.0: {}
@@ -10281,32 +10151,6 @@ snapshots:
 
   snabbdom@3.6.2: {}
 
-  socket.io-client@2.5.0(bufferutil@4.1.0)(utf-8-validate@5.0.10):
-    dependencies:
-      backo2: 1.0.2
-      component-bind: 1.0.0
-      component-emitter: 1.3.1
-      debug: 3.1.0
-      engine.io-client: 3.5.6(bufferutil@4.1.0)(utf-8-validate@5.0.10)
-      has-binary2: 1.0.3
-      indexof: 0.0.1
-      parseqs: 0.0.6
-      parseuri: 0.0.6
-      socket.io-parser: 3.3.4
-      to-array: 0.1.4
-    transitivePeerDependencies:
-      - bufferutil
-      - supports-color
-      - utf-8-validate
-
-  socket.io-parser@3.3.4:
-    dependencies:
-      component-emitter: 1.3.1
-      debug: 3.1.0
-      isarray: 2.0.1
-    transitivePeerDependencies:
-      - supports-color
-
   sortablejs@1.14.0: {}
 
   sortablejs@1.15.0: {}
@@ -10507,8 +10351,6 @@ snapshots:
       fdir: 6.4.2(picomatch@4.0.2)
       picomatch: 4.0.2
 
-  to-array@0.1.4: {}
-
   to-regex-range@5.0.1:
     dependencies:
       is-number: 7.0.0
@@ -10686,11 +10528,6 @@ snapshots:
       punycode: 1.4.1
       qs: 6.13.1
 
-  utf-8-validate@5.0.10:
-    dependencies:
-      node-gyp-build: 4.8.4
-    optional: true
-
   util-deprecate@1.0.2: {}
 
   uuid@10.0.0: {}
@@ -10953,19 +10790,12 @@ snapshots:
       imurmurhash: 0.1.4
       signal-exit: 4.1.0
 
-  ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10):
-    optionalDependencies:
-      bufferutil: 4.1.0
-      utf-8-validate: 5.0.10
-
   xml-js@1.6.11:
     dependencies:
       sax: 1.4.1
 
   xml-name-validator@4.0.0: {}
 
-  xmlhttprequest-ssl@1.6.3: {}
-
   y18n@4.0.3: {}
 
   y18n@5.0.8: {}
@@ -11013,8 +10843,6 @@ snapshots:
       y18n: 5.0.8
       yargs-parser: 21.1.1
 
-  yeast@0.1.2: {}
-
   yocto-queue@0.1.0: {}
 
   yocto-queue@1.1.1: {}

+ 4 - 0
src/api/pms/device/index.ts

@@ -156,6 +156,10 @@ export const IotDeviceApi = {
     return await request.get({ url: `/rq/iot-device/td/page`, params })
   },
 
+  getIotDeviceOliConnectPage: async (params: any) => {
+    return await request.get({ url: `/rq/iot-device/td/ly/page`, params })
+  },
+
   // 新增时根据部门id获取设备列表
   getIotDeviceSetOptions: async (id: any) => {
     return await request.get({ url: `/rq/iot-device/dept/${id}` })

+ 3 - 0
src/components/AnimatedCountTo/index.ts

@@ -0,0 +1,3 @@
+import AnimatedCountTo from './index.vue'
+
+export { AnimatedCountTo }

+ 36 - 0
src/components/AnimatedCountTo/index.vue

@@ -0,0 +1,36 @@
+<script setup lang="ts">
+import type { Format, Value } from '@number-flow/vue'
+import type { HTMLAttributes } from 'vue'
+import NumberFlow from '@number-flow/vue'
+
+defineOptions({
+  name: 'FaAnimatedCountTo'
+})
+
+const props = defineProps<{
+  value: Value
+  format?: Format
+  locales?: string
+  prefix?: string
+  suffix?: string
+  trend?: 1 | 0 | -1
+  transformTiming?: EffectTiming
+  spinTiming?: EffectTiming
+  opacityTiming?: EffectTiming
+  willChange?: boolean
+  class?: HTMLAttributes['class']
+}>()
+
+const emit = defineEmits<{
+  animationsstart: []
+  animationsfinish: []
+}>()
+</script>
+
+<template>
+  <NumberFlow
+    v-bind="props"
+    @animationsstart="emit('animationsstart')"
+    @animationsfinish="emit('animationsfinish')"
+  />
+</template>

+ 1 - 1
src/components/ZmTable/ZmTableColumn.vue

@@ -5,7 +5,7 @@ import { Filter } from '@element-plus/icons-vue'
 import { SortOrder, TableContextKey } from './token'
 
 interface Props extends /* @vue-ignore */ Partial<Omit<TableColumnCtx<T>, 'prop'>> {
-  prop: (keyof T & string) | (string & {})
+  prop?: (keyof T & string) | (string & {})
   zmSortable?: boolean
   zmFilterable?: boolean
   filterModelValue?: any

+ 22 - 2
src/components/ZmTable/index.vue

@@ -5,7 +5,7 @@ import { FilterPayload, SortField, SortOrder, TableContextKey } from './token'
 interface Props extends /* @vue-ignore */ Partial<Omit<TableProps<T>, 'data'>> {
   data: T[]
   loading: boolean
-  handleQuery: (payload?: FilterPayload) => void
+  handleQuery?: (payload?: FilterPayload) => void
   sortingFields?: SortField[]
   sortFn?: (prop: string, order: SortOrder | null) => void
 }
@@ -57,7 +57,7 @@ const handleDefaultSort = (prop: string, order: SortOrder | null) => {
   }
 
   emits('update:sortingFields', newFields)
-  props.handleQuery()
+  props.handleQuery?.()
 }
 
 const safeSortingFields = computed(() => props.sortingFields || [])
@@ -118,6 +118,10 @@ defineExpose({
   }
 
   .el-table__header {
+    overflow: hidden;
+    border-bottom-right-radius: 8px;
+    border-bottom-left-radius: 8px;
+
     .el-table__cell {
       background: var(--el-fill-color-light) !important;
 
@@ -134,6 +138,22 @@ defineExpose({
     }
   }
 
+  .el-table__body-wrapper {
+    margin-top: 6px;
+
+    .el-table__cell {
+      &:last-child {
+        border-top-right-radius: 8px;
+        border-bottom-right-radius: 8px;
+      }
+
+      &:first-child {
+        border-bottom-left-radius: 8px;
+        border-top-left-radius: 8px;
+      }
+    }
+  }
+
   .el-table__row {
     &:last-child {
       .el-table__cell {

+ 13 - 0
src/router/modules/remaining.ts

@@ -544,6 +544,19 @@ const remainingRouter: AppRouteRecordRaw[] = [
           activeMenu: '/device/info'
         }
       },
+      {
+        path: '/oli-connection/monitoring/detail',
+        component: () => import('@/views/oli-connection/monitoring/detail.vue'),
+        name: 'MonitoringDetail',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          icon: 'fa:area-chart',
+          title: t('rem.MonitoringDetails'),
+          activeMenu: '/oli-connection/monitoring'
+        }
+      },
       {
         path: 'device/upload/:id',
         component: () => import('@/views/pms/device/DeviceUpload.vue'),

+ 0 - 0
src/views/pms/device/monitor/color.ts → src/utils/td-color.ts


+ 2 - 1
src/views/test/useSocketBus.ts → src/utils/useSocketBus.ts

@@ -4,7 +4,8 @@ import { useWebSocket } from '@vueuse/core'
 type EventHandler = (data: any) => void
 
 export function useSocketBus(deviceCode: string) {
-  const url = `ws://192.168.188.149:8080/ws/${deviceCode}`
+  const url = `ws://172.21.10.65:8080/ws/${deviceCode}`
+  // const url = `ws://192.168.188.149:8080/ws/${deviceCode}`
 
   // 响应式状态
   const status = ref<'CONNECTING' | 'OPEN' | 'CLOSED'>('CLOSED')

+ 780 - 0
src/views/oli-connection/monitoring/detail.vue

@@ -0,0 +1,780 @@
+<script setup lang="ts">
+import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
+import { IotDeviceApi } from '@/api/pms/device'
+import dayjs from 'dayjs'
+import { rangeShortcuts } from '@/utils/formatTime'
+import { IotStatApi, cancelAllRequests } from '@/api/pms/stat'
+
+import * as echarts from 'echarts'
+import { colors } from '@/utils/td-color'
+import { useSocketBus } from '@/utils/useSocketBus'
+
+const { query } = useRoute()
+
+const data = ref({
+  deviceCode: query.code || '',
+  deviceName: query.name || '',
+  lastInlineTime: query.time || '',
+  ifInline: query.ifInline || '',
+  dept: query.dept || '',
+  vehicle: query.vehicle || '',
+  carOnline: query.carOnline || ''
+})
+
+const { open: connect, onAny, close } = useSocketBus(data.value.deviceCode as string)
+
+onAny((msg) => {
+  if (!Array.isArray(msg) || msg.length === 0) return
+
+  const valueMap = new Map<string, number>()
+
+  // 1️⃣ 一次遍历:建 Map + 推图表数据
+  for (const item of msg) {
+    const { identity, modelName, readTime, logValue } = item
+
+    const value = logValue ? Number(logValue) : 0
+
+    if (identity) {
+      valueMap.set(identity, value)
+    }
+
+    if (modelName && chartData.value[modelName]) {
+      chartData.value[modelName].push({
+        ts: dayjs(readTime).valueOf(),
+        value
+      })
+
+      updateSingleSeries(modelName)
+    }
+  }
+
+  // 2️⃣ 批量更新 dimensions
+  const updateDimensions = (list) => {
+    list.forEach((item) => {
+      const v = valueMap.get(item.identifier)
+      if (v !== undefined) {
+        item.value = v
+      }
+    })
+  }
+
+  updateDimensions(dimensions.value)
+  updateDimensions(gatewayDimensions.value)
+  updateDimensions(carDimensions.value)
+
+  // 3️⃣ 统一一次调用
+  genderIntervalArr()
+})
+
+interface Dimensions {
+  identifier: string
+  name: string
+  value: string
+  color?: string
+  response?: boolean
+}
+
+const dimensions = ref<Dimensions[]>([])
+const gatewayDimensions = ref<Dimensions[]>([])
+const carDimensions = ref<Dimensions[]>([])
+
+const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
+
+interface SelectedDimension {
+  [key: Dimensions['name']]: boolean
+}
+
+const selectedDimension = ref<SelectedDimension>({})
+
+const dimensionLoading = ref(false)
+
+const disabledDimension = computed(() => (identifier: string) => {
+  const response = dimensions.value.find((item) => item.identifier === identifier)?.response
+
+  return { disabled: disabledDimensions.value.includes(identifier) || response, loading: response }
+})
+
+async function loadDimensions() {
+  if (!query.id) return
+
+  dimensionLoading.value = true
+
+  const gateway = (((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as any[]) ?? [])
+    .sort((a, b) => b.modelOrder - a.modelOrder)
+    .map((item) => ({
+      identifier: item.identifier,
+      name: item.modelName,
+      value: item.value
+    }))
+  const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
+    .sort((a, b) => b.modelOrder - a.modelOrder)
+    .map((item) => ({
+      identifier: item.identifier,
+      name: item.modelName,
+      value: item.value
+    }))
+
+  dimensions.value = [...gateway, ...car]
+    .filter((item) => !disabledDimensions.value.includes(item.identifier))
+    .map((item, index) => ({
+      ...item,
+      color: colors[index]
+    }))
+
+  gatewayDimensions.value = gateway
+  carDimensions.value = car
+
+  selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
+
+  selectedDimension.value[dimensions.value[0].name] = true
+
+  dimensionLoading.value = false
+}
+
+// async function updateDimensionValues() {
+//   if (!query.id) return
+
+//   try {
+//     // 1. 并行获取最新数据
+//     const [gatewayRes, carRes] = await Promise.all([
+//       IotDeviceApi.getIotDeviceTds(Number(query.id)),
+//       IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))
+//     ])
+
+//     // 2. 创建一个 Map 用于快速查找 (Identifier -> Value)
+//     // 这样可以将复杂度从 O(N*M) 降低到 O(N)
+//     const newValueMap = new Map<string, any>()
+
+//     const addToMap = (data: any[]) => {
+//       if (!data) return
+//       data.forEach((item) => {
+//         if (item.identifier) {
+//           newValueMap.set(item.identifier, item.value)
+//         }
+//       })
+//     }
+
+//     addToMap(gatewayRes as any[])
+//     addToMap(carRes as any[])
+
+//     // 3. 更新 dimensions.value (保留了之前的 color 和其他属性)
+//     dimensions.value.forEach((item) => {
+//       if (newValueMap.has(item.identifier)) {
+//         item.value = newValueMap.get(item.identifier)
+//       }
+//     })
+
+//     // 4. 如果还需要同步更新 gatewayDimensions 和 carDimensions
+//     // (假设这些是引用类型,如果它们引用的是同一个对象,上面更新 dimensions 时可能已经同步了。
+//     // 如果它们是独立的对象数组,则需要显式更新)
+
+//     // 更新 Gateway 原始列表
+//     gatewayDimensions.value.forEach((item) => {
+//       if (newValueMap.has(item.identifier)) {
+//         item.value = newValueMap.get(item.identifier)
+//       }
+//     })
+
+//     // 更新 Car 原始列表
+//     carDimensions.value.forEach((item) => {
+//       if (newValueMap.has(item.identifier)) {
+//         item.value = newValueMap.get(item.identifier)
+//       }
+//     })
+//   } catch (error) {
+//     console.error('Failed to update dimension values:', error)
+//   }
+// }
+
+const selectedDate = ref<string[]>([
+  dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
+  dayjs().format('YYYY-MM-DD HH:mm:ss')
+])
+
+interface ChartData {
+  [key: Dimensions['name']]: { ts: number; value: number }[]
+}
+
+const chartData = ref<ChartData>({})
+
+let intervalArr = ref<number[]>([])
+let maxInterval = ref(0)
+let minInterval = ref(0)
+
+const chartRef = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | null = null
+
+// const genderIntervalArrDebounce = useDebounceFn(
+//   (init: boolean = false) => genderIntervalArr(init),
+//   300
+// )
+
+function genderIntervalArr(init: boolean = false) {
+  const values: number[] = []
+
+  for (const [key, value] of Object.entries(selectedDimension.value)) {
+    if (value) {
+      values.push(...(chartData.value[key]?.map((item) => item.value) ?? []))
+    }
+  }
+
+  const maxVal = values.length === 0 ? 10000 : Math.max(...values)
+  const minVal = values.length === 0 ? 0 : Math.min(...values) > 0 ? 0 : Math.min(...values)
+
+  const maxDigits = (Math.floor(maxVal) + '').length
+  const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
+
+  const interval = Math.max(maxDigits, minDigits)
+
+  maxInterval.value = interval
+  minInterval.value = minDigits
+
+  intervalArr.value = [0]
+  for (let i = 1; i <= interval; i++) {
+    intervalArr.value.push(Math.pow(10, i))
+  }
+
+  if (!init) {
+    chart?.setOption({
+      yAxis: {
+        min: -minInterval.value,
+        max: maxInterval.value
+      }
+    })
+  }
+}
+
+function chartInit() {
+  if (!chart) return
+
+  chart.on('legendselectchanged', (params: any) => {
+    selectedDimension.value = params.selected
+  })
+
+  window.addEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+}
+
+function render() {
+  if (!chartRef.value) return
+
+  if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
+
+  chartInit()
+
+  genderIntervalArr(true)
+
+  chart.setOption({
+    animation: true,
+    animationDuration: 200,
+    animationEasing: 'linear',
+    animationDurationUpdate: 200,
+    animationEasingUpdate: 'linear',
+    grid: {
+      left: '6%',
+      top: '5%',
+      right: '6%',
+      bottom: '12%'
+    },
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'line'
+      },
+      formatter: (params) => {
+        let d = `${params[0].axisValueLabel}<br>`
+        const exist: string[] = []
+        params = params.filter((el) => {
+          if (exist.includes(el.seriesName)) return false
+          exist.push(el.seriesName)
+          return true
+        })
+        let item = params.map(
+          (el) => `<div class="flex items-center justify-between mt-1">
+            <span>${el.marker} ${el.seriesName}</span>
+            <span>${el.value[2]?.toFixed(2)}</span>
+          </div>`
+        )
+
+        return d + item.join('')
+      }
+    },
+    xAxis: {
+      type: 'time',
+      axisLabel: {
+        formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
+        rotate: 0,
+        align: 'left'
+      }
+    },
+    dataZoom: [
+      { type: 'inside', xAxisIndex: 0 },
+      { type: 'slider', xAxisIndex: 0 }
+    ],
+    yAxis: {
+      type: 'value',
+      min: -minInterval.value,
+      max: maxInterval.value,
+      interval: 1,
+      axisLabel: {
+        formatter: (v) => {
+          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
+
+          return num.toLocaleString()
+        }
+      },
+      show: false
+    },
+    legend: {
+      data: dimensions.value.map((item) => item.name),
+      selected: selectedDimension.value,
+      show: false
+    },
+    // series: dimensions.value.map((item) => ({
+    //   name: item.name,
+    //   type: 'line',
+    //   smooth: true,
+    //   showSymbol: false,
+    //   color: item.color,
+    //   data: [] // 占位数组
+    // }))
+    series: dimensions.value.map((item) => ({
+      name: item.name,
+      type: 'line',
+
+      smooth: 0.2,
+
+      showSymbol: false,
+
+      endLabel: {
+        show: true,
+        formatter: (params) => params.value[2]?.toFixed(2),
+        offset: [6, 0],
+        color: item.color,
+        fontSize: 12
+      },
+
+      emphasis: {
+        focus: 'series'
+      },
+
+      lineStyle: {
+        width: 2
+      },
+
+      color: item.color,
+      data: [] // 占位数组
+    }))
+  })
+}
+
+function mapData({ value, ts }) {
+  if (!value) return [ts, 0, 0]
+
+  const isPositive = value > 0
+  const absItem = Math.abs(value)
+
+  const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
+  const min_index = intervalArr.value.findIndex((v) => v === min_value)
+
+  const new_value =
+    (absItem - min_value) / (intervalArr.value[min_index + 1] - intervalArr.value[min_index]) +
+    min_index
+
+  return [ts, isPositive ? new_value : -new_value, value]
+}
+
+function updateSingleSeries(name: string) {
+  if (!chart) render()
+  if (!chart) return
+
+  const idx = dimensions.value.findIndex((item) => item.name === name)
+  if (idx === -1) return
+
+  const data = chartData.value[name].map((v) => mapData(v))
+
+  chart.setOption({
+    series: [{ name, data }]
+  })
+}
+
+const lastTsMap = ref<Record<Dimensions['name'], number>>({})
+
+// async function fetchIncrementData() {
+//   for (const item of dimensions.value) {
+//     const { identifier, name } = item
+
+//     const lastTs = lastTsMap.value[name]
+//     if (!lastTs) continue
+
+//     item.response = true
+
+//     IotStatApi.getDeviceInfoChart(
+//       data.value.deviceCode,
+//       identifier,
+//       dayjs(lastTs).format('YYYY-MM-DD HH:mm:ss'),
+//       dayjs().format('YYYY-MM-DD HH:mm:ss')
+//     )
+//       .then((res) => {
+//         if (!res.length) return
+
+//         const sorted = res
+//           .sort((a, b) => a.ts - b.ts)
+//           .map((item) => ({ ts: item.ts, value: item.value }))
+//         // push 到本地
+//         chartData.value[name].push(...sorted)
+//         // 更新 lastTs
+//         lastTsMap.value[identifier] = sorted.at(-1).ts
+
+//         // 更新图表
+//         updateSingleSeries(name)
+//       })
+//       .finally(() => {
+//         item.response = false
+//       })
+//   }
+// }
+
+// const timer = ref<NodeJS.Timeout | null>(null)
+
+// function startAutoFetch() {
+//   timer.value = setInterval(() => {
+//     updateDimensionValues()
+//     fetchIncrementData()
+//   }, 10000)
+// }
+
+// function stopAutoFetch() {
+//   cancelAllRequests()
+//   if (timer.value) clearInterval(timer.value)
+//   timer.value = null
+// }
+
+const chartLoading = ref(false)
+
+async function initLoadChartData(real_time: boolean = true) {
+  if (!dimensions.value.length) return
+
+  chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
+
+  chartLoading.value = true
+
+  dimensions.value = dimensions.value.map((item) => {
+    item.response = true
+    return item
+  })
+
+  for (const item of dimensions.value) {
+    const { identifier, name } = item
+    try {
+      const res = await IotStatApi.getDeviceInfoChart(
+        data.value.deviceCode,
+        identifier,
+        selectedDate.value[0],
+        selectedDate.value[1]
+      )
+
+      const sorted = res
+        .sort((a, b) => a.ts - b.ts)
+        .map((item) => ({ ts: item.ts, value: item.value }))
+
+      chartData.value[name] = sorted
+
+      lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
+
+      updateSingleSeries(name)
+
+      chartLoading.value = false
+
+      if (selectedDimension.value[name]) {
+        genderIntervalArr()
+      }
+    } finally {
+      item.response = false
+    }
+  }
+
+  if (real_time) {
+    // startAutoFetch()
+    connect()
+  }
+}
+
+async function initfn(load: boolean = true, real_time: boolean = true) {
+  if (load) await loadDimensions()
+  render()
+  initLoadChartData(real_time)
+}
+
+onMounted(() => {
+  initfn()
+})
+
+function reset() {
+  cancelAllRequests().then(() => {
+    selectedDate.value = [
+      dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
+      dayjs().format('YYYY-MM-DD HH:mm:ss')
+    ]
+
+    close()
+    // stopAutoFetch()
+    if (chart) chart.clear()
+    initfn(false)
+  })
+}
+
+function handleDateChange() {
+  cancelAllRequests().then(() => {
+    close()
+    // stopAutoFetch()
+    if (chart) chart.clear()
+    initfn(false, false)
+  })
+}
+
+function handleClickSpec(modelName: string) {
+  selectedDimension.value[modelName] = !selectedDimension.value[modelName]
+  chart?.setOption({
+    legend: {
+      selected: selectedDimension.value
+    }
+  })
+  chart?.resize()
+  genderIntervalArr()
+}
+
+const exportChart = () => {
+  if (!chart) return
+  let img = new Image()
+  img.src = chart.getDataURL({
+    type: 'png',
+    pixelRatio: 1,
+    backgroundColor: '#fff'
+  })
+
+  img.onload = function () {
+    let canvas = document.createElement('canvas')
+    canvas.width = img.width
+    canvas.height = img.height
+    let ctx = canvas.getContext('2d')
+    ctx?.drawImage(img, 0, 0)
+    let dataURL = canvas.toDataURL('image/png')
+
+    let a = document.createElement('a')
+
+    let event = new MouseEvent('click')
+
+    a.href = dataURL
+    a.download = `${data.value.deviceName}-设备监控-${dayjs().format('YYYY-MM-DD HH:mm:ss')}.png`
+    a.dispatchEvent(event)
+  }
+}
+
+const maxmin = computed(() => {
+  if (!dimensions.value.length) return []
+  return dimensions.value
+    .filter((v) => selectedDimension.value[v.name])
+    .map((v) => ({
+      name: v.name,
+      color: v.color,
+      max: Math.max(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2),
+      min: Math.min(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2)
+    }))
+})
+
+onUnmounted(() => {
+  // stopAutoFetch()
+  close()
+
+  window.removeEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+})
+</script>
+
+<template>
+  <div
+    class="w-full bg-gradient-to-r from-blue-100 to-white rounded-lg p-6 shadow"
+    id="td-device-info"
+  >
+    <h2 class="flex items-center gap-2">
+      <el-icon class="text-sky!"><Odometer /></el-icon> 设备基础信息
+    </h2>
+    <el-form size="large" label-position="top" class="mt-4 grid grid-cols-3 gap-4">
+      <el-form-item label="资产编码"> {{ data.deviceCode }} </el-form-item>
+      <el-form-item label="设备类别"> {{ data.deviceName }} </el-form-item>
+      <el-form-item label="所在部门"> {{ data.dept }} </el-form-item>
+      <el-form-item label="网关状态" class="online" type="plain">
+        <el-tag
+          v-if="data.ifInline === '3'"
+          type="success"
+          size="default"
+          class="flex items-center"
+        >
+          <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
+          在线
+        </el-tag>
+
+        <el-tag v-if="data.ifInline === '4'" type="danger" size="default" class="flex items-center">
+          <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
+          离线
+        </el-tag>
+      </el-form-item>
+      <el-form-item v-if="data.carOnline" label="中航北斗" class="online" type="plain">
+        <el-tag
+          v-if="data.carOnline === 'true'"
+          type="success"
+          size="default"
+          class="flex items-center"
+        >
+          <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
+          在线
+        </el-tag>
+
+        <el-tag
+          v-if="data.carOnline === 'false'"
+          type="danger"
+          size="default"
+          class="flex items-center"
+        >
+          <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
+          离线
+        </el-tag>
+      </el-form-item>
+      <el-form-item label="最后数据时间"> {{ data.lastInlineTime }} </el-form-item>
+      <el-form-item v-if="data.vehicle" label="车牌号码"> {{ data.vehicle }} </el-form-item>
+    </el-form>
+  </div>
+  <div class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow min-h-50">
+    <header class="font-medium text-center w-full">网关数采</header>
+    <div
+      v-loading="dimensionLoading"
+      element-loading-background="transparent"
+      class="w-full mt-4 grid grid-cols-4 gap-4 min-h-30"
+      id="dimension"
+    >
+      <button
+        v-for="item in gatewayDimensions"
+        :key="item.identifier"
+        class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-8 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
+        :class="{ 'bg-blue-200': selectedDimension[item.name] }"
+        :disabled="disabledDimension(item.identifier).disabled"
+        @click="handleClickSpec(item.name)"
+      >
+        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2 relative">
+          <!-- <i
+            v-show="disabledDimension(item.identifier).loading"
+            class="i-line-md:loading-loop size-5 absolute -left-6"
+          ></i> -->
+          {{ item.name }}
+        </span>
+        <!-- <span class="text-lg font-medium ms-a">{{ item.value }}</span> -->
+        <animated-count-to :value="item.value" will-change class="text-lg font-medium ms-a" />
+      </button>
+    </div>
+  </div>
+  <div
+    v-if="carDimensions.length"
+    class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow"
+  >
+    <header class="font-medium text-center w-full">中航北斗</header>
+    <div class="w-full mt-4 grid grid-cols-4 gap-4" id="dimension">
+      <button
+        v-for="item in carDimensions"
+        :key="item.identifier"
+        class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
+        :class="{ 'bg-blue-200': selectedDimension[item.name] }"
+        :disabled="disabledDimension(item.identifier).disabled"
+        @click="handleClickSpec(item.name)"
+      >
+        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2">
+          <!-- <i
+            v-show="disabledDimension(item.identifier).loading"
+            class="i-line-md:loading-loop size-5"
+          ></i> -->
+          {{ item.name }}
+        </span>
+        <span class="text-lg font-medium ms-a">{{ item.value }}</span>
+      </button>
+    </div>
+  </div>
+  <div class="w-full mt-4 bg-gradient-to-r from-blue-100 to-white min-h-175 rounded-lg p-6 shadow">
+    <header class="flex items-center justify-between">
+      <h3 class="flex items-center gap-2">
+        <div class="i-material-symbols:area-chart-outline-rounded text-sky size-6" text-sky></div>
+        数据趋势
+      </h3>
+      <div class="flex gap-4">
+        <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
+        <el-button size="default" @click="reset">重置</el-button>
+        <el-date-picker
+          v-model="selectedDate"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="datetimerange"
+          unlink-panels
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :shortcuts="rangeShortcuts"
+          size="default"
+          class="w-100!"
+          placement="bottom-end"
+          @change="handleDateChange"
+        />
+      </div>
+    </header>
+    <div class="flex h-160 mt-4">
+      <div class="flex gap-1">
+        <button
+          v-for="item of maxmin"
+          :key="item.name"
+          class="w-8 h-full flex flex-col items-center justify-between py-2 gap-1 rounded bg-transparent border-none"
+          @click="handleClickSpec(item.name)"
+        >
+          <span class="[writing-mode:sideways-lr]">{{ item.max }}</span>
+          <div class="flex-1 w-1" :style="{ backgroundColor: item.color }"></div>
+          <span class="[writing-mode:sideways-lr]">{{ item.name }}</span>
+          <div class="flex-1 w-1" :style="{ backgroundColor: item.color }"></div>
+          <span class="[writing-mode:sideways-lr]">{{ item.min }}</span>
+        </button>
+      </div>
+      <div class="flex flex-1">
+        <div
+          v-loading="chartLoading"
+          element-loading-background="transparent"
+          ref="chartRef"
+          class="flex-1 h-full"
+        >
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+:deep(.el-form-item) {
+  margin-bottom: 0;
+
+  .el-form-item__label {
+    margin-bottom: 0;
+  }
+
+  .el-form-item__content {
+    font-size: 1rem;
+    font-weight: 500;
+  }
+
+  &.online {
+    .el-form-item__content {
+      height: 2.5rem;
+
+      .el-tag__content {
+        display: flex;
+        align-items: center;
+        gap: 2px;
+      }
+    }
+  }
+}
+</style>

+ 531 - 0
src/views/oli-connection/monitoring/index.vue

@@ -0,0 +1,531 @@
+<script setup lang="ts">
+import { IotDeviceApi } from '@/api/pms/device'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { useUserStore } from '@/store/modules/user'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { useDebounceFn } from '@vueuse/core'
+
+const { t } = useI18n()
+
+const id = useUserStore().getUser.deptId ?? 157
+
+const deptId = id
+
+interface Query {
+  deptId?: number
+  deviceName?: string
+  deviceCode?: string
+  ifInline?: number
+  pageNo: number
+  pageSize: number
+}
+
+const viewMode = ref('card')
+const query = ref<Query>({
+  pageNo: 1,
+  pageSize: 12,
+  deptId: id
+})
+
+interface OliDevice {
+  id: number
+  carId?: number
+  deviceName: string
+  deviceCode: string
+  assetClassName: string
+  deviceStatus: string
+  ifInline: number
+  lastInlineTime: string
+  carOnline: string
+  deptName: string
+  vehicleName: string
+}
+
+const list = ref<OliDevice[]>([])
+const total = ref(0)
+
+const loading = ref(false)
+
+const loadList = useDebounceFn(async function () {
+  loading.value = true
+  try {
+    const data = await IotDeviceApi.getIotDeviceOliConnectPage(query.value)
+    // const data = await IotDeviceApi.getIotDeviceTdPage(query.value)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+})
+
+function handleSizeChange(val: number) {
+  query.value.pageSize = val
+  handleQuery()
+}
+
+function handleCurrentChange(val: number) {
+  query.value.pageNo = val
+  loadList()
+}
+
+function handleQuery(setPage = true) {
+  if (setPage) {
+    query.value.pageNo = 1
+  }
+  loadList()
+}
+
+function resetQuery() {
+  query.value = {
+    pageNo: 1,
+    pageSize: 12,
+    deptId: id
+  }
+
+  handleQuery()
+}
+
+watch(
+  [
+    () => query.value.deptId,
+    () => query.value.deviceName,
+    () => query.value.deviceCode,
+    () => query.value.ifInline
+  ],
+  () => {
+    handleQuery()
+  },
+  { immediate: true }
+)
+
+const { ZmTable, ZmTableColumn } = useTableComponents<OliDevice>()
+
+const getStatusConfig = (status: number) => {
+  switch (status) {
+    case 3: // 在线
+      return {
+        bg: 'bg-gradient-to-br from-emerald-400 to-cyan-500',
+        icon: 'ep:success-filled',
+        label: '在线',
+        textColor: 'text-emerald-600'
+      }
+    case 4: // 离线
+      return {
+        bg: 'bg-gradient-to-br from-slate-400 to-slate-500',
+        icon: 'ep:circle-close-filled',
+        label: '离线',
+        textColor: 'text-slate-500'
+      }
+    case 5: // 未激活
+      return {
+        bg: 'bg-gradient-to-br from-blue-400 to-indigo-500',
+        icon: 'ep:info-filled',
+        label: '未激活',
+        textColor: 'text-blue-500'
+      }
+    case 2: // 禁用
+      return {
+        bg: 'bg-gradient-to-br from-orange-400 to-red-500',
+        icon: 'ep:warn-triangle-filled',
+        label: '禁用',
+        textColor: 'text-red-500'
+      }
+    default:
+      return {
+        bg: 'bg-gradient-to-br from-gray-300 to-gray-400',
+        icon: 'ep:question-filled',
+        label: '未知',
+        textColor: 'text-gray-500'
+      }
+  }
+}
+
+const message = useMessage()
+const router = useRouter()
+
+const openDetail = (
+  id: number,
+  ifInline: number,
+  time: string,
+  name: string,
+  code: string,
+  dept: string,
+  vehicle: string,
+  carOnline: string
+) => {
+  if (time === null || time === undefined) {
+    message.warning('没有数采数据')
+    return
+  }
+  router.push({
+    name: 'MonitoringDetail',
+    query: { id, ifInline, carOnline, time, name, code, dept, vehicle }
+  })
+}
+</script>
+
+<template>
+  <div
+    class="grid grid-cols-[15%_1fr] grid-rows-[62px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
+  >
+    <div class="p-4 bg-white dark:bg-[#1d1e1f] shadow rounded-lg row-span-2">
+      <DeptTreeSelect
+        :top-id="156"
+        :deptId="deptId"
+        v-model="query.deptId"
+        :init-select="false"
+        :show-title="false"
+      />
+    </div>
+    <el-form
+      size="default"
+      class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 gap-8 flex items-center justify-between"
+    >
+      <div class="flex items-center gap-8">
+        <el-form-item :label="t('monitor.deviceName')">
+          <el-input
+            v-model="query.deviceName"
+            :placeholder="t('monitor.nameHolder')"
+            clearable
+            @keyup.enter="handleQuery()"
+            class="!w-240px"
+          />
+        </el-form-item>
+        <el-form-item :label="t('monitor.deviceCode')">
+          <el-input
+            v-model="query.deviceCode"
+            :placeholder="t('monitor.codeHolder')"
+            clearable
+            @keyup.enter="handleQuery()"
+            class="!w-240px"
+          />
+        </el-form-item>
+        <el-form-item :label="t('monitor.ifInline')">
+          <el-select
+            v-model="query.ifInline"
+            :placeholder="t('monitor.ifInlineHolder')"
+            clearable
+            class="!w-240px"
+          >
+            <el-option
+              v-for="dict in getStrDictOptions(DICT_TYPE.IOT_DEVICE_STATUS)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleQuery()">
+            <Icon icon="ep:search" class="mr-5px" /> 搜索
+          </el-button>
+          <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
+        </el-form-item>
+      </div>
+      <el-form-item>
+        <el-button-group>
+          <el-button :type="viewMode === 'card' ? 'primary' : 'default'" @click="viewMode = 'card'">
+            <Icon icon="ep:grid" />
+          </el-button>
+          <el-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="viewMode = 'list'">
+            <Icon icon="ep:list" />
+          </el-button>
+        </el-button-group>
+      </el-form-item>
+    </el-form>
+    <div
+      class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg flex flex-col"
+      :class="{ 'p-4': viewMode === 'list' }"
+    >
+      <div class="flex-1 relative">
+        <el-auto-resizer class="absolute">
+          <template #default="{ width, height }">
+            <zm-table
+              v-if="viewMode === 'list'"
+              :data="list"
+              :loading="loading"
+              :width="width"
+              :max-height="height"
+              :height="height"
+            >
+              <zm-table-column type="index" :label="t('monitor.serial')" :width="60" />
+              <zm-table-column prop="deviceName" :label="t('monitor.deviceName')" />
+              <zm-table-column prop="deviceCode" :label="t('monitor.deviceCode')" />
+              <zm-table-column prop="assetClassName" :label="t('monitor.category')" />
+              <zm-table-column prop="deviceStatus" :label="t('monitor.status')" />
+              <zm-table-column prop="ifInline" :label="t('monitor.ifInline')">
+                <template #default="scope">
+                  <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="scope.row.ifInline" />
+                </template>
+              </zm-table-column>
+              <zm-table-column prop="lastInlineTime" :label="t('monitor.latestDataTime')" />
+              <zm-table-column :label="t('monitor.operation')" :width="60">
+                <template #default="scope">
+                  <el-button
+                    link
+                    type="primary"
+                    @click="
+                      openDetail(
+                        scope.row.id,
+                        scope.row.ifInline,
+                        scope.row.lastInlineTime,
+                        scope.row.deviceName,
+                        scope.row.deviceCode,
+                        scope.row.deptName,
+                        scope.row.vehicleName,
+                        scope.row.carOnline ?? ''
+                      )
+                    "
+                  >
+                    {{ t('monitor.check') }}
+                  </el-button>
+                </template>
+              </zm-table-column>
+            </zm-table>
+            <el-scrollbar
+              v-else
+              :height="height"
+              :class="width"
+              view-class="grid grid-cols-4 grid-rows-3 gap-4 p-4"
+            >
+              <!-- <div
+                v-for="item in list"
+                :key="item.id"
+                class="group relative flex flex-col bg-white dark:bg-[#262727] rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 hover:shadow-xl hover:-translate-y-1 transition-all duration-300 overflow-hidden"
+              >
+                <div
+                  class="h-[110px] px-4 pt-3 pb-1 flex flex-col justify-between"
+                  :class="getStatusConfig(item.ifInline).bg"
+                >
+                  <div class="flex justify-between items-center text-white">
+                    <div class="bg-white/20 p-1.5 rounded-full backdrop-blur-sm">
+                      <Icon icon="ep:cpu" class="text-lg" />
+                    </div>
+
+                    <div
+                      class="flex items-center gap-1 bg-black/20 backdrop-blur-md px-2 py-0.5 rounded-full text-xs font-medium"
+                    >
+                      <Icon :icon="getStatusConfig(item.ifInline).icon" />
+                      <span>{{ getStatusConfig(item.ifInline).label }}</span>
+                    </div>
+                  </div>
+
+                  <div class="text-white mb-1">
+                    <div class="text-lg font-bold truncate drop-shadow-md" :title="item.deviceName">
+                      {{ item.deviceName }}
+                    </div>
+                    <div class="text-xs opacity-80 font-mono truncate">
+                      {{ item.deviceCode }}
+                    </div>
+                  </div>
+                </div>
+
+                <div
+                  class="flex-1 p-4 flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-300"
+                >
+                  <div
+                    class="flex items-center justify-between border-b border-gray-50 dark:border-gray-700 pb-2"
+                  >
+                    <span class="text-gray-400 text-xs flex items-center gap-1">
+                      <Icon icon="ep:postcard" /> 编码
+                    </span>
+                    <span
+                      class="font-mono font-medium truncate max-w-[140px]"
+                      :title="item.deviceCode"
+                    >
+                      {{ item.deviceCode }}
+                    </span>
+                  </div>
+
+                  <div
+                    class="flex items-center justify-between border-b border-gray-50 dark:border-gray-700 pb-2"
+                  >
+                    <span class="text-gray-400 text-xs flex items-center gap-1">
+                      <Icon icon="ep:price-tag" /> 类别
+                    </span>
+                    <el-tag
+                      size="small"
+                      type="info"
+                      effect="plain"
+                      class="!border-none !bg-gray-100 dark:!bg-gray-800"
+                    >
+                      {{ item.assetClassName || '-' }}
+                    </el-tag>
+                  </div>
+
+                  <div class="flex items-center justify-between">
+                    <span class="text-gray-400 text-xs flex items-center gap-1">
+                      <Icon icon="ep:clock" /> 最后上线
+                    </span>
+                    <span class="text-xs">
+                      {{ item.lastInlineTime || '从未上线' }}
+                    </span>
+                  </div>
+                </div>
+
+                <div
+                  class="p-3 bg-gray-50 dark:bg-[#1d1e1f] border-t border-gray-100 dark:border-gray-700 flex justify-end"
+                >
+                  <el-button
+                    type="primary"
+                    link
+                    class="!px-2 group-hover:scale-105 transition-transform"
+                  >
+                    查看详情 <Icon icon="ep:arrow-right" class="ml-1" />
+                  </el-button>
+                </div>
+              </div> -->
+              <div
+                v-for="item in list"
+                :key="item.id"
+                class="group relative flex flex-col bg-white dark:bg-[#262727] rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 hover:shadow-[0_8px_20px_rgba(0,0,0,0.1)] hover:-translate-y-1 transition-all duration-300 overflow-hidden"
+              >
+                <div
+                  class="h-[80px] px-4 flex items-center justify-between overflow-hidden"
+                  :class="getStatusConfig(item.ifInline).bg"
+                >
+                  <div class="flex items-center gap-3 z-10 max-w-[95%]">
+                    <div class="bg-white/20 p-2 rounded-lg backdrop-blur-md shadow-inner shrink-0">
+                      <Icon :icon="item.carId ? 'ep:van' : 'ep:cpu'" class="text-xl text-white" />
+                    </div>
+
+                    <!-- 文本区域 -->
+                    <div class="flex flex-col overflow-hidden">
+                      <el-tooltip effect="dark" :content="item.deviceName" placement="top-start">
+                        <span
+                          class="text-white font-bold text-base truncate leading-tight"
+                          :title="item.deviceName"
+                        >
+                          {{ item.deviceName }}
+                        </span>
+                      </el-tooltip>
+
+                      <span class="text-white/80 text-xs font-mono truncate mt-0.5">
+                        {{ item.deviceCode }}
+                      </span>
+                    </div>
+                  </div>
+
+                  <div class="z-10 shrink-0">
+                    <div
+                      class="flex items-center gap-1.5 bg-black/20 backdrop-blur-md px-2.5 py-1 rounded-full text-xs font-medium text-white shadow-sm border border-white/10"
+                    >
+                      <div
+                        class="w-1.5 h-1.5 rounded-full bg-white animate-pulse"
+                        v-if="item.ifInline === 3"
+                      >
+                      </div>
+                      <Icon :icon="getStatusConfig(item.ifInline).icon" v-else />
+                      <span>{{ getStatusConfig(item.ifInline).label }}</span>
+                    </div>
+                  </div>
+                </div>
+
+                <!-- 内容区域 -->
+                <div
+                  class="flex-1 p-4 flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-300 relative z-20 bg-white dark:bg-[#262727]"
+                >
+                  <!-- 编码行 -->
+                  <div class="flex items-center justify-between pb-2">
+                    <span class="text-gray-400 text-xs flex items-center gap-1.5">
+                      <Icon icon="ep:postcard" /> 设备编码
+                    </span>
+                    <span
+                      class="font-mono font-medium truncate max-w-[140px] select-all"
+                      :title="item.deviceCode"
+                    >
+                      {{ item.deviceCode }}
+                    </span>
+                  </div>
+
+                  <!-- 类别行 -->
+                  <div class="flex items-center justify-between pb-2">
+                    <span class="text-gray-400 text-xs flex items-center gap-1.5">
+                      <Icon icon="ep:price-tag" /> 设备类别
+                    </span>
+                    <el-tag
+                      size="small"
+                      type="info"
+                      effect="light"
+                      round
+                      class="!bg-gray-100 dark:!bg-gray-800 !border-gray-200 dark:!border-gray-600"
+                    >
+                      {{ item.assetClassName || '-' }}
+                    </el-tag>
+                  </div>
+
+                  <!-- 时间行 -->
+                  <div class="flex items-center justify-between">
+                    <span class="text-gray-400 text-xs flex items-center gap-1.5">
+                      <Icon icon="ep:clock" /> 最后上线
+                    </span>
+                    <span
+                      class="text-xs font-medium"
+                      :class="
+                        item.lastInlineTime ? 'text-gray-600 dark:text-gray-300' : 'text-gray-300'
+                      "
+                    >
+                      {{ item.lastInlineTime || '暂无记录' }}
+                    </span>
+                  </div>
+                </div>
+
+                <!-- 底部操作栏 -->
+                <div
+                  class="px-4 py-2.5 bg-gray-50/80 dark:bg-[#1d1e1f] flex justify-between items-center group-hover:bg-blue-50/30 dark:group-hover:bg-blue-900/10 transition-colors"
+                >
+                  <span class="text-[10px] text-gray-400">ID: {{ item.id }}</span>
+                  <el-button type="primary" link size="small" class="!px-0 group/btn">
+                    <span
+                      class="mr-1 group-hover/btn:underline"
+                      @click="
+                        openDetail(
+                          item.id,
+                          item.ifInline,
+                          item.lastInlineTime,
+                          item.deviceName,
+                          item.deviceCode,
+                          item.deptName,
+                          item.vehicleName,
+                          item.carOnline ?? ''
+                        )
+                      "
+                      >查看详情</span
+                    >
+                    <Icon
+                      icon="ep:arrow-right"
+                      class="group-hover/btn:translate-x-1 transition-transform"
+                    />
+                  </el-button>
+                </div>
+              </div>
+            </el-scrollbar>
+          </template>
+        </el-auto-resizer>
+      </div>
+      <div
+        class="h-10 mt-4 flex items-center justify-end"
+        :class="{ 'mb-4 px-4': viewMode === 'card' }"
+      >
+        <el-pagination
+          size="default"
+          v-show="total > 0"
+          v-model:current-page="query.pageNo"
+          v-model:page-size="query.pageSize"
+          :background="true"
+          :page-sizes="[12, 20, 30, 50, 100]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+:deep(.el-form-item) {
+  margin-bottom: 0;
+}
+</style>

+ 0 - 330
src/views/pms/device/monitor/TdDeviceInfo copy.vue

@@ -1,330 +0,0 @@
-<template>
-  <ContentWrap v-loading="formLoading">
-    <ContentWrap>
-      <el-form style="height: 89px; margin-left: 20px">
-        <el-row style="display: flex; flex-direction: row">
-          <el-col :span="8">
-            <el-form-item prop="deviceCode">
-              <template #label>
-                <span class="custom-label">资产编码:</span>
-              </template>
-              <span class="custom-label">{{ formData.deviceCode }}</span>
-            </el-form-item>
-          </el-col>
-          <el-col :span="8">
-            <el-form-item prop="deviceName">
-              <template #label>
-                <span class="custom-label">设备类别:</span>
-              </template>
-              <span class="custom-label">{{ formData.deviceName }}</span>
-            </el-form-item>
-          </el-col>
-          <el-col :span="8">
-            <el-form-item prop="dept">
-              <template #label>
-                <span class="custom-label">所在部门:</span>
-              </template>
-              <span class="custom-label">{{ formData.dept }}</span>
-            </el-form-item>
-          </el-col>
-          <el-col :span="8">
-            <el-form-item prop="ifInline">
-              <template #label>
-                <span class="custom-label">是否在线:</span>
-              </template>
-              <template #default>
-                <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="formData.ifInline" />
-              </template>
-            </el-form-item>
-          </el-col>
-          <el-col :span="8">
-            <el-form-item prop="lastInlineTime">
-              <template #label>
-                <span class="custom-label">最后数据时间:</span>
-              </template>
-              <span class="custom-label">{{ formData.lastInlineTime }}</span>
-            </el-form-item>
-          </el-col>
-          <el-col :span="8">
-            <el-form-item v-if="formData.vehicle" prop="vehicle">
-              <template #label>
-                <span class="custom-label">车牌号码:</span>
-              </template>
-              <span class="custom-label">{{ formData.vehicle }}</span>
-            </el-form-item>
-          </el-col>
-        </el-row>
-      </el-form>
-    </ContentWrap>
-    <ContentWrap>
-      <el-row>
-        <el-col :span="24">
-          <TdDeviceLabel :tags="specs" @select="labelSelect" tag-width="24%" />
-        </el-col>
-      </el-row>
-    </ContentWrap>
-    <ContentWrap>
-      <div class="chart-container">
-        <!-- 图表容器 -->
-        <el-date-picker
-          v-model="dateRange"
-          type="datetimerange"
-          :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
-          start-placeholder="起始日期时间"
-          end-placeholder="结束日期时间"
-          format="YYYY-MM-DD HH:mm:ss"
-          value-format="YYYY-MM-DD HH:mm:ss"
-          @change="handleDateChange"
-        />
-        <div v-loading="loading" style="height: 100%" ref="chartContainer"></div>
-      </div>
-    </ContentWrap>
-  </ContentWrap>
-</template>
-
-<script setup lang="ts">
-import { DICT_TYPE } from '@/utils/dict'
-import TdDeviceLabel from '@/views/pms/device/monitor/TdDeviceLabel.vue'
-import { IotDeviceApi } from '@/api/pms/device'
-import * as echarts from 'echarts'
-import dayjs from 'dayjs'
-import { IotStatApi } from '@/api/pms/stat'
-import { IotAlarmSettingApi } from '@/api/pms/alarm'
-
-const { params, name } = useRoute() // 查询参数
-const info = ref({})
-const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
-const id = params.id
-defineOptions({ name: 'TdDeviceDetail' })
-const formData = ref({
-  deviceCode: '',
-  deviceName: '',
-  ifInline: undefined,
-  lastInlineTime: '',
-  dept: '',
-  vehicle: ''
-})
-const specs = ref([])
-
-// 响应式数据
-const startTime = ref('')
-const endTime = ref('')
-const topicName = ref([])
-const loading = ref(false)
-const topic = ref('')
-// 设置固定阈值
-
-const handleDateChange = async (val) => {
-  if (val && val.length === 2) {
-    await getChart(val)
-    await renderChart()
-  }
-}
-
-const defaultEnd = dayjs()
-const defaultStart = defaultEnd.subtract(1, 'day')
-const dateRange = ref([
-  defaultStart.format('YYYY-MM-DD HH:mm:ss'),
-  defaultEnd.format('YYYY-MM-DD HH:mm:ss')
-])
-const labelSelect = async (row) => {
-  topic.value = row.identifier
-  topicName.value = row.modelName
-  await getChart(dateRange.value)
-  await renderChart()
-}
-
-const chartContainer = ref(null)
-let chartInstance = null
-
-// 时间格式化(HH:mm)
-const formatTime = (timestamp) => {
-  return new Date(timestamp)
-    .toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
-    .slice(0, 5)
-}
-const result = ref([])
-const getChart = async (range) => {
-  loading.value = true
-  await IotStatApi.getDeviceInfoChart(params.code, topic.value, range[0], range[1]).then((res) => {
-    result.value = res
-    loading.value = false
-  })
-}
-// 初始化图表
-const renderChart = async () => {
-  if (!chartContainer.value) return
-  let upperLimit
-  let lowerLimit
-  await IotAlarmSettingApi.getDeviceRange(params.code, topic.value).then((res) => {
-    if (res) {
-      if (res.maxValue) {
-        upperLimit = res.maxValue
-      }
-      if (res.minValue) {
-        lowerLimit = res.minValue
-      }
-    }
-  })
-  // 销毁旧实例
-  if (chartInstance) chartInstance.dispose()
-
-  chartInstance = markRaw(echarts.init(chartContainer.value))
-
-  const option = {
-    title: {
-      text: topicName.value + '数据趋势',
-      left: 'center'
-    },
-    tooltip: { trigger: 'axis' },
-    xAxis: {
-      type: 'category',
-      data: result.value.map((d) => dayjs(d.timestamp).format('YYYY-MM-DD HH:mm:ss')),
-      axisLabel: { rotate: 45 },
-      inverse: true
-    },
-    yAxis: {
-      type: 'value'
-      // 根据固定阈值和实际数据调整Y轴范围,使阈值线更清晰
-      // min: Math.min(lowerLimit * 0.9, ...result.value.map(d => d.value || 0)),
-      // max: Math.max(upperLimit * 1.03, ...result.value.map(d => d.value || 0))
-    },
-    dataZoom: [
-      {
-        type: 'slider',
-        xAxisIndex: 0,
-        start: 0,
-        end: 100
-      }
-    ],
-    series: [
-      // 原始数据曲线
-      {
-        data: result.value.map((d) => d.value),
-        type: 'line',
-        smooth: true,
-        name: '实时数据',
-        lineStyle: { color: '#409eff' }
-      },
-      // 上限阈值线(固定100)
-      {
-        data: result.value.map(() => upperLimit),
-        type: 'line',
-        name: '上限阈值',
-        lineStyle: {
-          color: '#f56c6c', // 红色虚线
-          type: 'dashed'
-        },
-        symbol: 'none', // 不显示数据点
-        emphasis: { disabled: true } // 禁用悬停高亮
-      },
-      // 下限阈值线(固定95)
-      {
-        data: result.value.map(() => lowerLimit),
-        type: 'line',
-        name: '下限阈值',
-        lineStyle: {
-          color: '#e6a23c', // 橙色虚线
-          type: 'dashed'
-        },
-        symbol: 'none',
-        emphasis: { disabled: true }
-      }
-    ],
-    // 添加图例显示各线条含义
-    legend: {
-      data: ['实时数据', '上限阈值', '下限阈值'],
-      top: 30
-    }
-  }
-
-  chartInstance.setOption(option)
-
-  // 窗口自适应
-  window.addEventListener('resize', () => chartInstance.resize())
-}
-onMounted(async () => {
-  formLoading.value = true
-  formData.value.deviceCode = params.code
-  formData.value.deviceName = params.name
-  formData.value.lastInlineTime = params.time
-  formData.value.ifInline = params.ifInline
-  formData.value.dept = params.dept
-  formData.value.vehicle = params.vehicle
-  await IotDeviceApi.getIotDeviceTds(id).then((res) => {
-    specs.value = res
-    specs.value = specs.value.sort((a, b) => {
-      return b.modelOrder - a.modelOrder
-    })
-    formLoading.value = false
-    topic.value = specs.value[0].identifier
-    topicName.value = specs.value[0].modelName
-  })
-  await getChart(dateRange.value)
-  await renderChart()
-})
-</script>
-<style scoped lang="scss">
-.container {
-  width: 100%;
-  margin: 20px auto;
-  padding: 24px;
-  //background: #f8f9fa;
-  border-radius: 12px;
-}
-.chart-container {
-  width: 100%;
-  height: 600px;
-  padding: 20px;
-  background: #fff;
-  border-radius: 8px;
-  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
-}
-
-.date-controls {
-  display: flex;
-  align-items: center;
-  gap: 15px;
-  margin-bottom: 20px;
-}
-
-input[type='datetime-local'] {
-  padding: 8px 12px;
-  border: 1px solid #dcdfe6;
-  border-radius: 4px;
-  transition: border-color 0.2s;
-}
-
-input[type='datetime-local']:focus {
-  border-color: #409eff;
-  outline: none;
-}
-
-.separator {
-  color: #606266;
-}
-
-.query-btn {
-  padding: 8px 20px;
-  background: #409eff;
-  color: white;
-  border: none;
-  border-radius: 4px;
-  cursor: pointer;
-  transition: opacity 0.2s;
-}
-
-.query-btn:hover {
-  opacity: 0.8;
-}
-
-//.chart {
-//  width: 100%;
-//  height: 500px;
-//  margin-top: 20px;
-//}
-.custom-label {
-  font-size: 17px;
-  font-weight: bold;
-}
-</style>

+ 1 - 1
src/views/pms/device/monitor/TdDeviceInfo.vue

@@ -6,7 +6,7 @@ import { rangeShortcuts } from '@/utils/formatTime'
 import { IotStatApi, cancelAllRequests } from '@/api/pms/stat'
 
 import * as echarts from 'echarts'
-import { colors } from './color'
+import { colors } from '@/utils/td-color'
 
 const { query } = useRoute()
 

+ 0 - 564
src/views/pms/device/monitor/TdDeviceInfo1.vue

@@ -1,564 +0,0 @@
-<script setup lang="ts">
-import * as echarts from 'echarts'
-import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
-import { rangeShortcuts } from '@/utils/formatTime'
-
-import dayjs from 'dayjs'
-import quarterOfYear from 'dayjs/plugin/quarterOfYear'
-import { IotDeviceApi, cancelAllRequests } from '@/api/pms/device'
-import { IotStatApi } from '@/api/pms/stat'
-
-defineOptions({ name: 'TdDeviceDetail' })
-
-dayjs.extend(quarterOfYear)
-
-const { query } = useRoute()
-
-const data = ref({
-  deviceCode: query.code || '',
-  deviceName: query.name || '',
-  lastInlineTime: query.time || '',
-  ifInline: query.ifInline || '',
-  dept: query.dept || '',
-  vehicle: query.vehicle || '',
-  carOnline: query.carOnline || ''
-})
-
-const disabledIdentifier = ref<string[]>(['online', 'vehicle_name', 'touchScreenDataAccumulate'])
-
-const specs = ref<any[]>([])
-const gatewayspecs = ref<any[]>([])
-const zhbdspecs = ref<any[]>([])
-const selectSpec = ref<Record<string, boolean>>({})
-const chartMap = ref<Record<string, { name: string; value: any[] }>>({})
-
-const specsLoading = ref(false)
-
-const lastTsMap = ref<Record<string, number>>({})
-
-// 每 10s 刷新定时器
-const timer = ref<NodeJS.Timeout | null>(null)
-
-const defaultDate = rangeShortcuts[0].value()
-const date = ref([
-  dayjs(defaultDate[0]).format('YYYY-MM-DD HH:mm:ss'),
-  dayjs(defaultDate[1]).format('YYYY-MM-DD HH:mm:ss')
-])
-
-const reset = () => {
-  cancelAllRequests().then(() => {
-    const def = rangeShortcuts[0].value()
-
-    date.value = [
-      dayjs(def[0]).format('YYYY-MM-DD HH:mm:ss'),
-      dayjs(def[1]).format('YYYY-MM-DD HH:mm:ss')
-    ]
-    stopAutoFetch()
-    if (chart) chart.clear()
-    render()
-    initLoad()
-  })
-}
-
-const handleDateChange = () => {
-  cancelAllRequests().then(() => {
-    stopAutoFetch()
-    if (chart) chart.clear()
-    render()
-    initLoad(false)
-  })
-}
-
-const handleClickSpec = (modelName: string) => {
-  selectSpec.value[modelName] = !selectSpec.value[modelName]
-  chart?.setOption({
-    legend: {
-      selected: selectSpec.value
-    }
-  })
-  // getIntervalArr()
-}
-
-const chartRef = ref<HTMLDivElement | null>(null)
-let chart: echarts.ECharts | null = null
-
-const exportChart = () => {
-  if (!chart) return
-  let img = new Image()
-  img.src = chart.getDataURL({
-    type: 'png',
-    pixelRatio: 1,
-    backgroundColor: '#fff'
-  })
-
-  img.onload = function () {
-    let canvas = document.createElement('canvas')
-    canvas.width = img.width
-    canvas.height = img.height
-    let ctx = canvas.getContext('2d')
-    ctx?.drawImage(img, 0, 0)
-    let dataURL = canvas.toDataURL('image/png')
-
-    let a = document.createElement('a')
-
-    let event = new MouseEvent('click')
-
-    a.href = dataURL
-    a.download = `${data.value.deviceName}-设备监控-${dayjs().format('YYYY-MM-DD HH:mm:ss')}.png`
-    a.dispatchEvent(event)
-  }
-}
-
-const chartInit = () => {
-  if (!chart) return
-
-  chart.on('legendselectchanged', (params: any) => {
-    selectSpec.value = params.selected
-  })
-
-  window.addEventListener('resize', () => {
-    if (chart) chart.resize()
-  })
-}
-
-// 映射区间相关
-let intervalArr = ref<number[]>([])
-let maxInterval = ref(0)
-let minInterval = ref(0)
-
-// 1. 加载 specs
-const loadSpecs = async () => {
-  if (!query.id) return
-  specsLoading.value = true
-  const res = await IotDeviceApi.getIotDeviceTds(Number(query.id))
-  const zhbdres = await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))
-
-  zhbdspecs.value = zhbdres.sort((a, b) => b.modelOrder - a.modelOrder)
-  gatewayspecs.value = res.sort((a, b) => b.modelOrder - a.modelOrder)
-
-  specs.value = [
-    ...JSON.parse(JSON.stringify(gatewayspecs.value)),
-    ...JSON.parse(JSON.stringify(zhbdspecs.value))
-  ]
-
-  selectSpec.value = specs.value.reduce(
-    (acc, spec, index: number) => {
-      acc[spec.modelName] = index === 0
-      return acc
-    },
-    {} as Record<string, boolean>
-  )
-
-  specsLoading.value = false
-
-  chartMap.value = specs.value
-    .filter((spec) => !disabledIdentifier.value.includes(spec.identifier))
-    .reduce(
-      (acc, spec) => {
-        acc[spec.identifier] = { name: spec.modelName, value: [] }
-        return acc
-      },
-      {} as Record<string, { name: string; value: any[] }>
-    )
-}
-
-const chartLoading = ref(false)
-
-const initLoad = async (real_time: boolean = true) => {
-  if (!specs.value.length) return
-
-  Object.keys(chartMap.value).forEach((identifier) => {
-    chartMap.value[identifier].value = []
-    lastTsMap.value[identifier] = 0
-  })
-
-  chartLoading.value = true
-
-  for (const identifier of Object.keys(chartMap.value)) {
-    const res = await IotStatApi.getDeviceInfoChart(
-      data.value.deviceCode,
-      identifier,
-      date.value[0],
-      date.value[1]
-    )
-
-    const sorted = res
-      .sort((a, b) => a.ts - b.ts)
-      .map((item) => ({
-        ts: item.ts,
-        value: item.value
-      }))
-    chartMap.value[identifier].value = sorted
-    lastTsMap.value[identifier] = sorted.at(-1)?.ts ?? 0
-
-    updateSingleSeries(identifier)
-
-    // if (selectSpec.value[chartMap.value[identifier].name]) {
-    //   getIntervalArr()
-    // }
-
-    chartLoading.value = false
-  }
-
-  console.log('chartMap.value :>> ', JSON.stringify(Object.values(chartMap.value), null, 2))
-
-  if (real_time) startAutoFetch()
-}
-
-const startAutoFetch = () => {
-  timer.value = setInterval(fetchIncrementData, 10000)
-}
-
-const stopAutoFetch = () => {
-  if (timer.value) clearInterval(timer.value)
-  timer.value = null
-}
-
-const fetchIncrementData = () => {
-  for (const identifier of Object.keys(chartMap.value)) {
-    const lastTs = lastTsMap.value[identifier]
-    if (!lastTs) continue
-
-    IotStatApi.getDeviceInfoChart(
-      data.value.deviceCode,
-      identifier,
-      dayjs(lastTs).format('YYYY-MM-DD HH:mm:ss'),
-      dayjs().format('YYYY-MM-DD HH:mm:ss')
-    ).then((res) => {
-      if (!res.length) return
-
-      const sorted = res.sort((a, b) => a.ts - b.ts)
-
-      // push 到本地
-      chartMap.value[identifier].value.push(...sorted)
-      // 更新 lastTs
-      lastTsMap.value[identifier] = sorted.at(-1).ts
-
-      appendToSeries(identifier, chartMap.value[identifier].value)
-    })
-  }
-}
-
-const getIntervalArr = (init: boolean = false) => {
-  const values: number[] = []
-
-  for (const [key, value] of Object.entries(selectSpec.value)) {
-    if (value) {
-      const identifier = specs.value.find((spec) => spec.modelName === key)?.identifier
-      values.push(...(chartMap.value[identifier]?.value?.map((item) => item.value) ?? []))
-    }
-  }
-
-  const maxVal = values.length === 0 ? 10000 : Math.max(...values)
-  const minVal = Math.min(...values, -100)
-
-  const maxDigits = (Math.floor(maxVal) + '').length
-  const minDigits = (Math.floor(Math.abs(minVal)) + '').length - 2
-  const interval = Math.max(maxDigits, minDigits)
-
-  maxInterval.value = interval
-  minInterval.value = minDigits
-
-  intervalArr.value = [0]
-  for (let i = 1; i <= interval; i++) {
-    intervalArr.value.push(Math.pow(10, i))
-  }
-
-  if (!init) {
-    chart?.setOption({
-      yAxis: {
-        min: -minInterval.value,
-        max: maxInterval.value
-      }
-    })
-  }
-}
-
-const render = () => {
-  if (!chartRef.value) return
-
-  if (!chart) chart = echarts.init(chartRef.value)
-
-  chartInit()
-
-  getIntervalArr(true)
-
-  chart.setOption({
-    tooltip: {
-      trigger: 'axis',
-      formatter: (params) => {
-        let d = `${params[0].axisValueLabel}<br>`
-        const exist: string[] = []
-        params = params.filter((el) => {
-          if (exist.includes(el.seriesName)) return false
-          exist.push(el.seriesName)
-          return true
-        })
-        let item = params.map(
-          (el) => `${el.marker} ${el.seriesName}: ${el.value[2].toFixed(2)}<br>`
-        )
-        return d + item.join('')
-      }
-    },
-    dataZoom: [
-      { type: 'inside', xAxisIndex: 0 },
-      { type: 'slider', xAxisIndex: 0 }
-    ],
-    xAxis: {
-      type: 'time',
-      axisLabel: {
-        formatter: (v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
-        rotate: 20
-      }
-    },
-    yAxis: {
-      type: 'value',
-      min: -minInterval.value,
-      max: maxInterval.value,
-      interval: 1,
-      axisLabel: {
-        formatter: (v) => {
-          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
-
-          return num.toLocaleString()
-        }
-      }
-    },
-    legend: {
-      data: Object.values(chartMap.value).map((i) => i.name),
-      selected: selectSpec.value,
-      show: false
-    },
-    series: Object.keys(chartMap.value).map((identifier) => ({
-      name: chartMap.value[identifier].name,
-      type: 'line',
-      smooth: true,
-      showSymbol: false,
-      data: [] // 占位数组
-    }))
-  })
-}
-
-const updateSingleSeries = (identifier: string) => {
-  if (!chart) render()
-  if (!chart) return
-
-  const idx = Object.keys(chartMap.value).indexOf(identifier)
-  if (idx === -1) return
-
-  const data = chartMap.value[identifier].value.map((v) => mapData(v))
-
-  chart.setOption({
-    series: [
-      {
-        name: chartMap.value[identifier].name,
-        data
-      }
-    ]
-  })
-}
-
-const appendToSeries = (identifier, list) => {
-  if (!chart) return
-
-  const idx = Object.keys(chartMap.value).indexOf(identifier)
-  if (idx === -1) return
-
-  const data = list.map(mapData)
-
-  chart.setOption({
-    series: [
-      {
-        name: chartMap.value[identifier].name,
-        data
-      }
-    ]
-  })
-}
-
-const mapData = ({ value, ts }) => {
-  if (value === 0) return [ts, 0, 0]
-
-  const isPositive = value > 0
-  const absItem = Math.abs(value)
-
-  const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
-  const min_index = intervalArr.value.findIndex((v) => v === min_value)
-
-  const new_value =
-    (absItem - min_value) / (intervalArr.value[min_index + 1] - intervalArr.value[min_index]) +
-    min_index
-
-  return [ts, isPositive ? new_value : -new_value, value]
-}
-
-onMounted(async () => {
-  await loadSpecs()
-  render()
-  initLoad()
-})
-
-onUnmounted(() => {
-  stopAutoFetch()
-
-  window.removeEventListener('resize', () => {
-    if (chart) chart.resize()
-  })
-})
-</script>
-
-<template>
-  <div
-    class="w-full bg-gradient-to-r from-blue-100 to-white rounded-lg p-6 shadow"
-    id="td-device-info"
-  >
-    <h2 class="flex items-center gap-2">
-      <el-icon class="text-sky!"><Odometer /></el-icon> 设备基础信息
-    </h2>
-    <el-form size="large" label-position="top" class="mt-4 grid grid-cols-3 gap-4">
-      <el-form-item label="资产编码"> {{ data.deviceCode }} </el-form-item>
-      <el-form-item label="设备类别"> {{ data.deviceName }} </el-form-item>
-      <el-form-item label="所在部门"> {{ data.dept }} </el-form-item>
-      <el-form-item label="网关状态" class="online" type="plain">
-        <el-tag
-          v-if="data.ifInline === '3'"
-          type="success"
-          size="default"
-          class="flex items-center"
-        >
-          <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
-          在线
-        </el-tag>
-
-        <el-tag v-if="data.ifInline === '4'" type="danger" size="default" class="flex items-center">
-          <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
-          离线
-        </el-tag>
-      </el-form-item>
-      <el-form-item v-if="data.carOnline" label="中航北斗" class="online" type="plain">
-        <el-tag
-          v-if="data.carOnline === 'true'"
-          type="success"
-          size="default"
-          class="flex items-center"
-        >
-          <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
-          在线
-        </el-tag>
-
-        <el-tag
-          v-if="data.carOnline === 'false'"
-          type="danger"
-          size="default"
-          class="flex items-center"
-        >
-          <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
-          离线
-        </el-tag>
-      </el-form-item>
-      <el-form-item label="最后数据时间"> {{ data.lastInlineTime }} </el-form-item>
-      <el-form-item v-if="data.vehicle" label="车牌号码"> {{ data.vehicle }} </el-form-item>
-    </el-form>
-  </div>
-  <div class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow min-h-50">
-    <header class="font-medium text-center w-full">网关数采</header>
-    <div
-      v-loading="specsLoading"
-      element-loading-background="transparent"
-      class="w-full mt-4 grid grid-cols-4 gap-4 min-h-30"
-      id="dimension"
-    >
-      <div
-        v-for="item in gatewayspecs"
-        :key="item.productId"
-        class="h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
-        :class="{
-          'bg-blue-200': selectSpec[item.modelName]
-        }"
-        @click="!disabledIdentifier.includes(item.identifier) && handleClickSpec(item.modelName)"
-      >
-        <span class="text-sm text-[var(--el-text-color-regular)]">{{ item.modelName }}</span>
-        <span class="text-lg font-medium ms-a">{{ item.value }}</span>
-      </div>
-    </div>
-  </div>
-  <div
-    v-if="zhbdspecs.length"
-    class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow"
-  >
-    <header class="font-medium text-center w-full">中航北斗</header>
-    <div class="w-full mt-4 grid grid-cols-4 gap-4" id="dimension">
-      <div
-        v-for="item in zhbdspecs"
-        :key="item.productId"
-        class="h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
-        :class="{
-          'bg-blue-200': selectSpec[item.modelName]
-        }"
-        @click="!disabledIdentifier.includes(item.identifier) && handleClickSpec(item.modelName)"
-      >
-        <span class="text-sm text-[var(--el-text-color-regular)]">{{ item.modelName }}</span>
-        <span class="text-lg font-medium ms-a">{{ item.value }}</span>
-      </div>
-    </div>
-  </div>
-  <div class="w-full mt-4 bg-gradient-to-r from-blue-100 to-white min-h-175 rounded-lg p-6 shadow">
-    <header class="flex items-center justify-between">
-      <h3 class="flex items-center gap-2">
-        <div class="i-material-symbols:area-chart-outline-rounded text-sky size-6" text-sky></div>
-        数据趋势
-      </h3>
-
-      <div class="flex gap-4">
-        <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
-        <el-button size="default" @click="reset">重置</el-button>
-        <el-date-picker
-          v-model="date"
-          value-format="YYYY-MM-DD HH:mm:ss"
-          type="datetimerange"
-          unlink-panels
-          start-placeholder="开始日期"
-          end-placeholder="结束日期"
-          :shortcuts="rangeShortcuts"
-          size="default"
-          class="w-100!"
-          placement="bottom-end"
-          @change="handleDateChange"
-        />
-      </div>
-    </header>
-    <div
-      v-loading="specsLoading"
-      element-loading-background="transparent"
-      ref="chartRef"
-      class="w-full h-158 mt-4 mb-4"
-    ></div>
-  </div>
-</template>
-
-<style lang="scss" scoped>
-:deep(.el-form-item) {
-  margin-bottom: 0;
-
-  .el-form-item__label {
-    margin-bottom: 0;
-  }
-
-  .el-form-item__content {
-    font-size: 1rem;
-    font-weight: 500;
-  }
-
-  &.online {
-    .el-form-item__content {
-      height: 2.5rem;
-
-      .el-tag__content {
-        display: flex;
-        align-items: center;
-        gap: 2px;
-      }
-    }
-  }
-}
-</style>

+ 0 - 21
src/views/test/index-aaa.vue

@@ -1,21 +0,0 @@
-<template>
-  <button @click="connect">连接</button>
-  <button @click="sendHello">发送 hello</button>
-</template>
-
-<script setup lang="ts">
-import { useSocketBus } from './useSocketBus'
-
-const deviceCode = 'YF6660355'
-const { on, sendEvent, open: connect, onAny } = useSocketBus(deviceCode)
-
-// 注册自定义事件
-on('server_reply_msg', (data) => console.log('📥 后端回复:', data))
-on('device_push_msg', (data) => console.log('📤 后端推送:', data))
-
-onAny((msg) => console.log('📡 任意事件:', msg))
-
-function sendHello() {
-  sendEvent('client_send_msg', { text: 'hello from useWebSocket', time: Date.now() })
-}
-</script>

+ 1 - 161
src/views/test/index.vue

@@ -1,4 +1,4 @@
-<!-- <script lang="ts" setup>
+<script lang="ts" setup>
 import { IotDeviceApi } from '@/api/pms/device'
 import { SortField } from '@/components/ZmTable/token'
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
@@ -93,164 +93,4 @@ const realValue = (value: string) => {
       <zm-table-column zm-filterable zm-sortable prop="assetOwnership" label="资产归属" />
     </zm-table>
   </div>
-</template> -->
-
-<script lang="ts" setup>
-// import { io, Socket } from 'socket.io-client'
-
-// let socket: Socket | null = null
-// const deviceCode = 'YF6660355'
-
-// /**
-//  * 连接
-//  */
-// function connect(): void {
-//   // const wsUrl = `http://121.36.34.44:8099/ws/device`
-//   const wsUrl = `http://121.36.34.44:8099/ws/device/${deviceCode}`
-//   // const wsUrl = `http://localhost:80`
-//   socket = io(wsUrl, {
-//     transports: ['websocket'],
-//     reconnection: true,
-//     withCredentials: true,
-//     reconnectionAttempts: 5,
-//     upgrade: false,
-//     timeout: 5000
-//   })
-
-//   socket.on('connect', () => {
-//     console.log('✅ socket.io 连接成功', socket?.id)
-//   })
-
-//   socket.on('server_reply_msg', (data) => {
-//     console.log(`📥 后端回复:${data}`)
-//   })
-
-//   socket.on('device_push_msg', (data) => {
-//     console.log(`📤 后端推送:${JSON.stringify(data)}`)
-//   })
-
-//   socket.on('disconnect', (reason) => {
-//     console.log('🔌 断开连接', reason)
-//   })
-
-//   socket.on('connect_error', (err) => {
-//     console.error('❌ 连接失败', err)
-//   })
-
-//   // 接收后端推送(等价于 /topic/device/{deviceCode})
-//   socket.on('device-msg', (data) => {
-//     console.log('📩 收到消息:', data)
-//   })
-// }
-
-// /**
-//  * 发送消息
-//  */
-// function sendMsg(): void {
-//   if (!socket || !socket.connected) {
-//     console.warn('⚠️ 还没连接')
-//     return
-//   }
-
-//   socket.emit('client_send_msg', {
-//     deviceCode,
-//     text: 'hello from socket.io',
-//     time: Date.now()
-//   })
-
-//   console.log('📤 消息已发送')
-// }
-
-// /**
-//  * 主动断开
-//  */
-// function disconnect(): void {
-//   socket?.disconnect()
-//   socket = null
-//   console.log('🔌 已主动断开')
-// }
-
-import io, { Socket } from 'socket.io-client'
-
-let socket: Socket | null = null
-const deviceCode = 'YF6660355'
-
-/**
- * 连接
- */
-function connect(): void {
-  const wsUrl = `http://121.36.34.44:8099/ws/device/${deviceCode}`
-
-  socket = io(wsUrl, {
-    // v2 里 transports 可以直接写数组
-    transports: ['websocket'],
-    // 是否自动重连
-    reconnection: true,
-    // 重连次数
-    reconnectionAttempts: 5,
-    // 重连间隔
-    reconnectionDelay: 1000,
-    // 超时时间
-    timeout: 5000
-    // 注意 v2 里没有 withCredentials 选项
-  })
-
-  socket.on('connect', () => {
-    console.log('✅ socket.io 连接成功', socket?.id)
-  })
-
-  socket.on('server_reply_msg', (data: any) => {
-    console.log(`📥 后端回复:${data}`)
-  })
-
-  socket.on('device_push_msg', (data: any) => {
-    console.log(`📤 后端推送:${JSON.stringify(data)}`)
-  })
-
-  socket.on('disconnect', (reason: string) => {
-    console.log('🔌 断开连接', reason)
-  })
-
-  socket.on('connect_error', (err: any) => {
-    console.error('❌ 连接失败', err)
-  })
-
-  // 设备消息推送
-  socket.on('device-msg', (data: any) => {
-    console.log('📩 收到消息:', data)
-  })
-}
-
-/**
- * 发送消息
- */
-function sendMsg(): void {
-  if (!socket || !socket.connected) {
-    console.warn('⚠️ 还没连接')
-    return
-  }
-
-  socket.emit('client_send_msg', {
-    deviceCode,
-    text: 'hello from socket.io',
-    time: Date.now()
-  })
-
-  console.log('📤 消息已发送')
-}
-
-/**
- * 主动断开
- */
-function disconnect(): void {
-  socket?.disconnect()
-  socket = null
-  console.log('🔌 已主动断开')
-}
-</script>
-
-<template>
-  <button @click="connect">连接</button>
-  <button @click="sendMsg">发送</button>
-  <button @click="disconnect">断开</button>
 </template>