Zimo 5 дней назад
Родитель
Сommit
ade6389add

+ 2 - 0
index.html

@@ -3,6 +3,8 @@
   <head>
     <script src="https://g.alicdn.com/code/npm/@ali/dingtalk-h5-remote-debug/0.1.3/index.js"></script>
     <script src="https://g.alicdn.com/dingding/dinglogin/0.0.5/ddLogin.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.6.1/dist/sockjs.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
     <script async type="text/javascript" src="/js/base64.min.js"></script>
     <script src="https://api.map.baidu.com/api?v=3.0&ak=c0crhdxQ5H7WcqbcazGr7mnHrLa4GmO0"></script>
     <meta charset="UTF-8" />

+ 198 - 0
pnpm-lock.yaml

@@ -161,6 +161,9 @@ 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
@@ -2405,6 +2408,9 @@ 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==}
 
@@ -2467,6 +2473,9 @@ 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'}
@@ -2511,12 +2520,19 @@ 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==}
 
@@ -2527,6 +2543,9 @@ 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==}
 
@@ -2576,6 +2595,10 @@ 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'}
@@ -2701,9 +2724,18 @@ 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==}
 
@@ -3122,6 +3154,12 @@ 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'}
@@ -3518,6 +3556,12 @@ 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'}
@@ -3604,6 +3648,9 @@ 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==}
 
@@ -3692,6 +3739,9 @@ 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==}
 
@@ -4138,6 +4188,10 @@ 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==}
 
@@ -4244,6 +4298,12 @@ 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==}
 
@@ -4692,6 +4752,12 @@ 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==}
 
@@ -4867,6 +4933,9 @@ 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'}
@@ -5015,6 +5084,10 @@ 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==}
 
@@ -5259,6 +5332,18 @@ 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
@@ -5267,6 +5352,10 @@ 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==}
 
@@ -5307,6 +5396,9 @@ 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'}
@@ -7795,6 +7887,8 @@ snapshots:
       global: 4.4.0
       pkcs7: 1.0.4
 
+  after@0.8.2: {}
+
   ajv@6.12.6:
     dependencies:
       fast-deep-equal: 3.1.3
@@ -7848,6 +7942,8 @@ snapshots:
 
   array-union@2.1.0: {}
 
+  arraybuffer.slice@0.0.7: {}
+
   astral-regex@2.0.0: {}
 
   async-validator@4.2.5: {}
@@ -7908,10 +8004,14 @@ 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
@@ -7920,6 +8020,8 @@ snapshots:
 
   binary-extensions@2.3.0: {}
 
+  blob@0.0.5: {}
+
   boolbase@1.0.0: {}
 
   bootstrap-icons@1.12.1: {}
@@ -7987,6 +8089,11 @@ 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:
@@ -8123,8 +8230,14 @@ 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: {}
@@ -8574,6 +8687,32 @@ 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: {}
@@ -9049,6 +9188,12 @@ 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:
@@ -9113,6 +9258,8 @@ snapshots:
 
   indent-string@4.0.0: {}
 
+  indexof@0.0.1: {}
+
   individual@2.0.0: {}
 
   inflight@1.0.6:
@@ -9174,6 +9321,8 @@ snapshots:
 
   is-url@1.2.4: {}
 
+  isarray@2.0.1: {}
+
   isexe@2.0.0: {}
 
   jackspeak@3.4.3:
@@ -9616,6 +9765,9 @@ 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
@@ -9724,6 +9876,10 @@ snapshots:
     dependencies:
       entities: 4.5.0
 
+  parseqs@0.0.6: {}
+
+  parseuri@0.0.6: {}
+
   path-browserify@1.0.1: {}
 
   path-exists@4.0.0: {}
@@ -10125,6 +10281,32 @@ 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: {}
@@ -10325,6 +10507,8 @@ 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
@@ -10502,6 +10686,11 @@ 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: {}
@@ -10764,12 +10953,19 @@ 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: {}
@@ -10817,6 +11013,8 @@ snapshots:
       y18n: 5.0.8
       yargs-parser: 21.1.1
 
+  yeast@0.1.2: {}
+
   yocto-queue@0.1.0: {}
 
   yocto-queue@1.1.1: {}

+ 291 - 0
src/components/ZmTable/ZmTableColumn.vue

@@ -0,0 +1,291 @@
+<script lang="ts" setup generic="T">
+import type { TableColumnCtx } from 'element-plus'
+import { computed, useAttrs, inject, ref } from 'vue'
+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 & {})
+  zmSortable?: boolean
+  zmFilterable?: boolean
+  filterModelValue?: any
+  realValue?: (value: any) => any
+}
+
+const emits = defineEmits(['update:filterModelValue'])
+const props = defineProps<Props>()
+const attrs = useAttrs()
+
+const tableContext = inject(TableContextKey, {
+  data: ref([]),
+  sortingFields: ref([]),
+  onQuery: () => {},
+  onSort: () => {},
+  loading: ref(false)
+})
+
+const defaultOptions = ref<Partial<Props>>({
+  align: 'center',
+  resizable: true,
+  showOverflowTooltip: true
+})
+
+const bindProps = computed(() => {
+  const resolvedAlign =
+    props.zmSortable || props.zmFilterable
+      ? 'left'
+      : attrs.align || props.align || defaultOptions.value.align
+
+  return {
+    ...defaultOptions.value,
+    ...attrs,
+    ...props,
+    prop: props.prop,
+    align: resolvedAlign,
+    className: (props.className ?? '') + ' ' + props.prop
+  }
+})
+
+const alignMap: Record<string, string> = {
+  center: 'justify-center',
+  left: 'justify-between',
+  right: 'justify-end'
+}
+const headerFlexClass = computed(
+  () => alignMap[bindProps.value.align as string] || 'justify-center'
+)
+
+const currentSortField = computed(() => {
+  if (!props.prop) return undefined
+  return tableContext.sortingFields.value.find((f: any) => f.field === props.prop)
+})
+
+const isSortActive = computed(() => !!currentSortField.value)
+
+const currentOrder = computed<SortOrder | undefined>(() => currentSortField.value?.order)
+
+const handleSortClick = () => {
+  if (!props.prop) return
+
+  let nextOrder: SortOrder | null = 'asc'
+
+  if (currentOrder.value === 'asc') {
+    nextOrder = 'desc'
+  } else if (currentOrder.value === 'desc') {
+    nextOrder = null
+  }
+
+  tableContext.onSort(props.prop, nextOrder)
+}
+
+const handleSearchClick = () => {
+  if (tableContext.onQuery && props.prop) {
+    tableContext.onQuery({
+      prop: props.prop,
+      value: props.filterModelValue
+    })
+  }
+}
+
+const getTextWidth = (text: string, fontSize = 14) => {
+  const span = document.createElement('span')
+  span.style.visibility = 'hidden'
+  span.style.position = 'absolute'
+  span.style.whiteSpace = 'nowrap'
+  span.style.fontSize = `${fontSize}px`
+  span.style.fontFamily = 'PingFang SC'
+  span.innerText = text
+
+  document.body.appendChild(span)
+  const width = span.offsetWidth
+  document.body.removeChild(span)
+
+  return width
+}
+
+const calculativeWidth = () => {
+  const values = tableContext.data.value
+    .map((item) => props.realValue?.(item[props.prop]) || item[props.prop])
+    .filter(Boolean)
+
+  let labelWidth = getTextWidth(bindProps.value.label || '') + 38
+
+  if (props.zmFilterable || props.zmSortable) {
+    labelWidth += 8
+  }
+
+  if (props.zmFilterable) labelWidth += 22
+  if (props.zmSortable) labelWidth += 22
+
+  const maxWidth = Math.max(...values.map((value) => getTextWidth(value) + 38), labelWidth)
+
+  defaultOptions.value.minWidth = maxWidth
+}
+
+watch(
+  () => tableContext.loading.value,
+  (loading) => {
+    if (!loading) {
+      nextTick(() => {
+        calculativeWidth()
+      })
+    }
+  },
+  { immediate: true }
+)
+</script>
+
+<template>
+  <el-table-column ref="columnRef" v-bind="bindProps">
+    <template v-for="(_, name) in $slots" :key="name" #[name]="slotData">
+      <slot v-if="name !== 'header'" :name="name" v-bind="slotData || {}"></slot>
+    </template>
+
+    <template #header="scope">
+      <slot name="header" v-bind="scope">
+        <div class="header-wrapper" :class="headerFlexClass">
+          <span class="truncate" :title="scope.column.label">{{ scope.column.label }}</span>
+          <div v-if="bindProps.zmSortable || bindProps.zmFilterable" class="action-area">
+            <el-tooltip
+              v-if="bindProps.zmSortable"
+              :content="
+                currentOrder === 'asc'
+                  ? '点击降序'
+                  : currentOrder === 'desc'
+                    ? '取消排序'
+                    : '点击升序'
+              "
+              placement="top"
+              :show-after="500"
+            >
+              <div
+                class="icon-btn"
+                :class="{ 'is-active': isSortActive }"
+                @click.stop="handleSortClick"
+              >
+                <i
+                  class="zm-sort-icon"
+                  :class="{
+                    'is-desc': currentOrder === 'desc'
+                  }"
+                ></i>
+              </div>
+            </el-tooltip>
+
+            <el-popover
+              v-if="bindProps.zmFilterable"
+              placement="top-end"
+              :popper-options="{ modifiers: [{ name: 'offset', options: { offset: [16, 18] } }] }"
+              trigger="click"
+              :width="260"
+              :show-arrow="false"
+            >
+              <template #reference>
+                <div
+                  class="icon-btn"
+                  :class="{ 'is-active': bindProps.filterModelValue }"
+                  @click.stop
+                >
+                  <el-icon :size="16"><Filter /></el-icon>
+                </div>
+              </template>
+
+              <slot name="filter" v-bind="scope">
+                <div class="flex gap-2 p-1">
+                  <el-input
+                    :model-value="bindProps.filterModelValue"
+                    @input="(val) => emits('update:filterModelValue', val)"
+                    placeholder="输入关键词"
+                    size="small"
+                    clearable
+                    @keydown.enter="handleSearchClick"
+                  />
+                  <el-button type="primary" size="small" @click="handleSearchClick">
+                    搜索
+                  </el-button>
+                </div>
+              </slot>
+            </el-popover>
+          </div>
+        </div>
+      </slot>
+    </template>
+  </el-table-column>
+</template>
+
+<style scoped lang="scss">
+.header-wrapper {
+  display: flex;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+  user-select: none;
+}
+
+.action-area {
+  display: flex;
+  height: 100%;
+  margin-left: 8px;
+  align-items: center;
+  gap: 4px;
+}
+
+.icon-btn {
+  display: flex;
+  width: 20px;
+  height: 20px;
+  color: var(--el-text-color-secondary);
+  cursor: pointer;
+  border-radius: 4px;
+  transition: background-color 0.2s;
+  align-items: center;
+  justify-content: center;
+
+  &:hover {
+    color: var(--el-color-primary);
+    background-color: var(--el-fill-color-darker);
+  }
+
+  &.is-active {
+    color: var(--el-color-primary);
+  }
+}
+
+.zm-sort-icon {
+  position: relative;
+  display: flex;
+  width: 12px;
+  height: 12px;
+  align-items: center;
+  justify-content: center;
+
+  &::before,
+  &::after {
+    position: absolute;
+    width: 8px;
+    height: 2px;
+    background-color: currentcolor;
+    border-radius: 2px;
+    content: '';
+    transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+  }
+
+  &::before {
+    transform: translateX(-2.2px) rotate(45deg);
+  }
+
+  &::after {
+    transform: translateX(2.2px) rotate(-45deg);
+  }
+
+  &.is-desc {
+    &::before {
+      transform: translateX(-2.2px) rotate(-45deg);
+    }
+
+    &::after {
+      transform: translateX(2.2px) rotate(45deg);
+    }
+  }
+}
+</style>

+ 145 - 0
src/components/ZmTable/index.vue

@@ -0,0 +1,145 @@
+<script lang="ts" setup generic="T">
+import type { TableInstance, TableProps } from 'element-plus'
+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
+  sortingFields?: SortField[]
+  sortFn?: (prop: string, order: SortOrder | null) => void
+}
+
+const props = defineProps<Props>()
+
+const emits = defineEmits<{
+  'update:sortingFields': [fields: SortField[]]
+}>()
+
+const attrs = useAttrs()
+const tableRef = ref<TableInstance>()
+
+const defaultOptions: Partial<Props> = {
+  size: 'default',
+  stripe: true,
+  border: true,
+  highlightCurrentRow: true,
+  showOverflowTooltip: true,
+  scrollbarAlwaysOn: false
+}
+
+const bindProps = computed(() => {
+  const { data, sortingFields, ...otherProps } = props
+
+  return {
+    ...defaultOptions,
+    ...attrs,
+    ...otherProps,
+    data: data || []
+  }
+})
+
+const handleDefaultSort = (prop: string, order: SortOrder | null) => {
+  const newFields = [...(props.sortingFields || [])]
+
+  const idx = newFields.findIndex((f) => f.field === prop)
+
+  if (order === null) {
+    if (idx > -1) {
+      newFields.splice(idx, 1)
+    }
+  } else {
+    if (idx > -1) {
+      newFields[idx] = { ...newFields[idx], order }
+    } else {
+      newFields.push({ field: prop, order })
+    }
+  }
+
+  emits('update:sortingFields', newFields)
+  props.handleQuery()
+}
+
+const safeSortingFields = computed(() => props.sortingFields || [])
+const safeData = computed(() => props.data || [])
+const safeLoading = computed(() => props.loading)
+
+provide(TableContextKey, {
+  onQuery: (payload) => props.handleQuery?.(payload),
+  onSort: (prop, order) => {
+    if (props.sortFn) {
+      props.sortFn(prop, order)
+    } else {
+      handleDefaultSort(prop, order)
+    }
+  },
+  // 关键:传递响应式的 data 和 sortingFields
+  data: safeData,
+  sortingFields: safeSortingFields,
+  loading: safeLoading
+})
+
+defineExpose({
+  elTableRef: tableRef
+})
+</script>
+
+<template>
+  <el-table ref="tableRef" v-loading="loading" class="zm-table" v-bind="bindProps" :data="data">
+    <template v-for="(_, name) in $slots" #[name]="slotData">
+      <slot :name="name" v-bind="slotData || {}"></slot>
+    </template>
+  </el-table>
+</template>
+
+<style>
+.zm-table {
+  border-radius: 8px;
+
+  &::before,
+  &::after {
+    display: none;
+  }
+
+  .el-table__inner-wrapper {
+    &::before,
+    &::after {
+      display: none;
+    }
+  }
+
+  .el-table__border-left-patch {
+    display: none;
+  }
+
+  .el-table__cell {
+    height: 52px;
+    border: none !important;
+  }
+
+  .el-table__header {
+    .el-table__cell {
+      background: var(--el-fill-color-light) !important;
+
+      .cell {
+        border-right: var(--el-table-border);
+        border-color: var(--el-table-header-text-color);
+      }
+
+      &:last-child {
+        .cell {
+          border-right: none;
+        }
+      }
+    }
+  }
+
+  .el-table__row {
+    &:last-child {
+      .el-table__cell {
+        border-bottom: none;
+      }
+    }
+  }
+}
+</style>

+ 23 - 0
src/components/ZmTable/token.ts

@@ -0,0 +1,23 @@
+import type { InjectionKey } from 'vue'
+
+export type SortOrder = 'asc' | 'desc'
+
+export interface SortField {
+  field: string
+  order: SortOrder
+}
+
+export interface FilterPayload {
+  prop: string
+  value: any
+}
+
+export interface TableContext<T = any> {
+  onQuery: (payload?: FilterPayload) => void
+  onSort: (prop: string, order: SortOrder | null) => void
+  data: Ref<T[]>
+  sortingFields: Ref<SortField[]>
+  loading: Ref<boolean>
+}
+
+export const TableContextKey: InjectionKey<TableContext> = Symbol('zm-table')

+ 9 - 0
src/components/ZmTable/useTableComponents.ts

@@ -0,0 +1,9 @@
+import ZmTable from './index.vue'
+import ZmTableColumn from './ZmTableColumn.vue'
+
+export function useTableComponents<T>() {
+  return {
+    ZmTable: ZmTable<T>,
+    ZmTableColumn: ZmTableColumn<T>
+  }
+}

+ 1 - 2
src/views/pms/device/index.vue

@@ -302,9 +302,8 @@
 import download from '@/utils/download'
 import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
 import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
-import { dateFormatter } from '@/utils/formatTime'
 import DeptTree from '@/views/system/user/DeptTree.vue'
-import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
+import { useCache } from '@/hooks/web/useCache'
 import { buildSortingField } from '@/utils'
 import { defaultProps, handleTree } from '@/utils/tree'
 import * as ProductClassifyApi from '@/api/pms/productclassify'

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

@@ -0,0 +1,21 @@
+<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>

+ 256 - 0
src/views/test/index.vue

@@ -0,0 +1,256 @@
+<!-- <script lang="ts" setup>
+import { IotDeviceApi } from '@/api/pms/device'
+import { SortField } from '@/components/ZmTable/token'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { DICT_TYPE, getDictOptions } from '@/utils/dict'
+
+interface List {
+  id: number
+  yfDeviceCode: string // 油服编码
+  deviceCode: string // 历史编码
+  deviceName: string // 设备名称
+  deptName: string // 所在部门
+  deviceStatus: string // 设备状态
+  assetProperty: string // 资产性质
+  assetClassName: string // 资产类别
+  manufacturer: string // 制造商
+  brandName: string // 品牌
+  model: string // 规格型号
+  chargeName: string // 负责人
+  useProject: string // 使用项目
+  assetOwnership: string // 资产归属
+}
+
+const loading = ref(false)
+const list = ref<List[]>([])
+const total = ref(0)
+
+const query = ref<Partial<List> & { pageNo: number; pageSize: number; sortingFields: SortField[] }>(
+  {
+    pageNo: 1,
+    pageSize: 10,
+    sortingFields: []
+  }
+)
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await IotDeviceApi.getIotDevicePage(query.value)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+getList()
+
+const { ZmTable, ZmTableColumn } = useTableComponents<List>()
+
+const realValue = (value: string) => {
+  const option = getDictOptions(DICT_TYPE.PMS_ASSET_PROPERTY).find((item) => item.value === value)
+  return option?.label || value
+}
+</script>
+
+<template>
+  <div class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg p-4 flex flex-col mt-4">
+    <zm-table
+      :data="list"
+      :loading="loading"
+      :handle-query="getList"
+      v-model:sorting-fields="query.sortingFields"
+    >
+      <zm-table-column
+        zm-filterable
+        v-model:filter-model-value="query.yfDeviceCode"
+        zm-sortable
+        prop="yfDeviceCode"
+        label="油服编码"
+      />
+      <zm-table-column zm-filterable zm-sortable prop="deviceCode" label="历史编码" />
+      <zm-table-column zm-filterable zm-sortable prop="deviceName" label="设备名称" />
+      <zm-table-column zm-filterable zm-sortable prop="deptName" label="所在部门" />
+      <zm-table-column zm-filterable zm-sortable prop="deviceStatus" label="设备状态" />
+      <zm-table-column
+        zm-filterable
+        zm-sortable
+        prop="assetProperty"
+        label="资产性质"
+        :real-value="realValue"
+      >
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PMS_ASSET_PROPERTY" :value="scope.row.assetProperty" />
+        </template>
+      </zm-table-column>
+      <zm-table-column zm-filterable zm-sortable prop="assetClassName" label="资产类别" />
+      <zm-table-column zm-filterable zm-sortable prop="manufacturer" label="制造商" />
+      <zm-table-column zm-filterable zm-sortable prop="brandName" label="品牌" />
+      <zm-table-column zm-filterable zm-sortable prop="model" label="规格型号" />
+      <zm-table-column zm-filterable zm-sortable prop="chargeName" label="负责人" />
+      <zm-table-column zm-filterable zm-sortable prop="useProject" label="使用项目" />
+      <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>

+ 78 - 0
src/views/test/useSocketBus.ts

@@ -0,0 +1,78 @@
+import { ref, reactive } from 'vue'
+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 status = ref<'CONNECTING' | 'OPEN' | 'CLOSED'>('CLOSED')
+  const messages = ref<any[]>([])
+
+  // 事件总线
+  const events: Record<string, EventHandler[]> = reactive({})
+
+  // 创建 WebSocket
+  const { ws, open, close, send } = useWebSocket(url, {
+    immediate: false,
+    autoReconnect: {
+      retries: 5,
+      delay: 1000
+    },
+    onConnected(_ws) {
+      console.log('✅ WebSocket 已连接')
+      status.value = 'OPEN'
+    },
+    onDisconnected() {
+      console.log('🔌 WebSocket 已断开')
+      status.value = 'CLOSED'
+    },
+    onMessage(_ws, event) {
+      try {
+        const msg = JSON.parse(event.data)
+        messages.value.push(msg)
+        if (msg.event && events[msg.event]) {
+          events[msg.event].forEach((cb) => cb(msg.data))
+        }
+
+        anyHandlers.forEach((cb) => cb(msg))
+      } catch (err) {
+        console.warn('非 JSON 消息:', event.data)
+      }
+    }
+  })
+
+  const anyHandlers: EventHandler[] = reactive([])
+
+  // 注册所有事件回调
+  function onAny(cb: EventHandler) {
+    anyHandlers.push(cb)
+  }
+
+  // 注册事件
+  function on(eventName: string, cb: EventHandler) {
+    if (!events[eventName]) events[eventName] = []
+    events[eventName].push(cb)
+  }
+
+  // 注销事件
+  function off(eventName: string, cb?: EventHandler) {
+    if (!cb) {
+      events[eventName] = []
+    } else {
+      events[eventName] = (events[eventName] || []).filter((fn) => fn !== cb)
+    }
+  }
+
+  // 发送带事件名的消息
+  function sendEvent(eventName: string, data: any) {
+    if (ws.value?.readyState === WebSocket.OPEN) {
+      send(JSON.stringify({ event: eventName, data }))
+    } else {
+      console.warn('⚠️ WebSocket 未连接')
+    }
+  }
+
+  return { ws, status, messages, on, off, sendEvent, open, close, onAny }
+}

+ 5 - 5
vite.config.ts

@@ -1,8 +1,8 @@
-import {resolve} from 'path'
-import type {ConfigEnv, UserConfig} from 'vite'
-import {loadEnv} from 'vite'
-import {createVitePlugins} from './build/vite'
-import {exclude, include} from "./build/vite/optimize"
+import { resolve } from 'path'
+import type { ConfigEnv, UserConfig } from 'vite'
+import { loadEnv } from 'vite'
+import { createVitePlugins } from './build/vite'
+import { include } from "./build/vite/optimize"
 // 当前执行node命令时文件夹的地址(工作目录)
 const root = process.cwd()