From e6453a900b883b8b13f9fe24e3517fb3aec4b2ae Mon Sep 17 00:00:00 2001 From: whyzxhnd Date: Thu, 16 Oct 2025 11:08:09 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A8=E5=8D=95=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- readme.md | 4 + web/package.json | 1 + web/pnpm-lock.yaml | 112 +++++ web/src/api/gas/alarmrule/index.ts | 56 +++ web/src/api/gas/alarmtype/index.ts | 51 ++ web/src/api/gas/factory/index.ts | 64 +++ web/src/api/gas/fence/index.ts | 50 ++ web/src/api/gas/fencealarm/index.ts | 56 +++ web/src/api/gas/gastype/index.ts | 50 ++ web/src/api/gas/handalarm/index.ts | 60 +++ web/src/api/gas/handdetector/index.ts | 63 +++ web/src/api/gas/index.ts | 7 + web/src/layout/components/Footer/src/Footer.vue | 4 +- web/src/locales/zh-CN.ts | 2 +- web/src/main.ts | 1 + web/src/store/modules/app.ts | 4 +- web/src/store/modules/handDetector.ts | 81 ++++ web/src/utils/dict.ts | 10 +- web/src/views/HandDevice/History/index.vue | 4 + .../HandDevice/Home/components/MapControls.vue | 139 ++++++ .../HandDevice/Home/components/OpenLayerMap.vue | 510 ++++++++++++++++++++ .../Home/components/TrajectoryControls.vue | 526 +++++++++++++++++++++ .../Home/components/composables/useMapEvents.ts | 273 +++++++++++ .../Home/components/composables/useMapServices.ts | 297 ++++++++++++ .../Home/components/composables/useMapWatchers.ts | 139 ++++++ .../composables/useTrajectoryControls.ts | 147 ++++++ .../Home/components/constants/map.constants.ts | 69 +++ .../Home/components/services/animation.service.ts | 162 +++++++ .../Home/components/services/fence-draw.service.ts | 264 +++++++++++ .../Home/components/services/fence.service.ts | 328 +++++++++++++ .../Home/components/services/map.service.ts | 114 +++++ .../Home/components/services/marker.service.ts | 196 ++++++++ .../Home/components/services/popup.service.ts | 86 ++++ .../Home/components/services/trajectory.service.ts | 502 ++++++++++++++++++++ .../HandDevice/Home/components/types/map.types.ts | 151 ++++++ .../HandDevice/Home/components/utils/map.utils.ts | 202 ++++++++ web/src/views/HandDevice/Home/index.vue | 37 ++ web/src/views/Home/Index.vue | 28 +- web/src/views/Login/components/LoginForm.vue | 4 +- web/src/views/gas/alarmrule/AlarmRuleForm.vue | 204 ++++++++ web/src/views/gas/alarmrule/index.vue | 268 +++++++++++ web/src/views/gas/alarmtype/AlarmTypeForm.vue | 136 ++++++ web/src/views/gas/alarmtype/index.vue | 237 ++++++++++ web/src/views/gas/factory/FactoryForm.vue | 190 ++++++++ web/src/views/gas/factory/index.vue | 400 ++++++++++++++++ web/src/views/gas/fence/FenceForm.vue | 132 ++++++ web/src/views/gas/fence/index.vue | 243 ++++++++++ web/src/views/gas/fencealarm/FenceAlarmForm.vue | 194 ++++++++ web/src/views/gas/fencealarm/index.vue | 337 +++++++++++++ web/src/views/gas/gastype/TypeForm.vue | 114 +++++ web/src/views/gas/gastype/index.vue | 217 +++++++++ web/src/views/gas/handalarm/HandAlarmForm.vue | 249 ++++++++++ web/src/views/gas/handalarm/index.vue | 333 +++++++++++++ .../views/gas/handdetector/HandDetectorForm.vue | 217 +++++++++ web/src/views/gas/handdetector/index.vue | 249 ++++++++++ 55 files changed, 8541 insertions(+), 33 deletions(-) create mode 100644 web/src/api/gas/alarmrule/index.ts create mode 100644 web/src/api/gas/alarmtype/index.ts create mode 100644 web/src/api/gas/factory/index.ts create mode 100644 web/src/api/gas/fence/index.ts create mode 100644 web/src/api/gas/fencealarm/index.ts create mode 100644 web/src/api/gas/gastype/index.ts create mode 100644 web/src/api/gas/handalarm/index.ts create mode 100644 web/src/api/gas/handdetector/index.ts create mode 100644 web/src/api/gas/index.ts create mode 100644 web/src/store/modules/handDetector.ts create mode 100644 web/src/views/HandDevice/History/index.vue create mode 100644 web/src/views/HandDevice/Home/components/MapControls.vue create mode 100644 web/src/views/HandDevice/Home/components/OpenLayerMap.vue create mode 100644 web/src/views/HandDevice/Home/components/TrajectoryControls.vue create mode 100644 web/src/views/HandDevice/Home/components/composables/useMapEvents.ts create mode 100644 web/src/views/HandDevice/Home/components/composables/useMapServices.ts create mode 100644 web/src/views/HandDevice/Home/components/composables/useMapWatchers.ts create mode 100644 web/src/views/HandDevice/Home/components/composables/useTrajectoryControls.ts create mode 100644 web/src/views/HandDevice/Home/components/constants/map.constants.ts create mode 100644 web/src/views/HandDevice/Home/components/services/animation.service.ts create mode 100644 web/src/views/HandDevice/Home/components/services/fence-draw.service.ts create mode 100644 web/src/views/HandDevice/Home/components/services/fence.service.ts create mode 100644 web/src/views/HandDevice/Home/components/services/map.service.ts create mode 100644 web/src/views/HandDevice/Home/components/services/marker.service.ts create mode 100644 web/src/views/HandDevice/Home/components/services/popup.service.ts create mode 100644 web/src/views/HandDevice/Home/components/services/trajectory.service.ts create mode 100644 web/src/views/HandDevice/Home/components/types/map.types.ts create mode 100644 web/src/views/HandDevice/Home/components/utils/map.utils.ts create mode 100644 web/src/views/HandDevice/Home/index.vue create mode 100644 web/src/views/gas/alarmrule/AlarmRuleForm.vue create mode 100644 web/src/views/gas/alarmrule/index.vue create mode 100644 web/src/views/gas/alarmtype/AlarmTypeForm.vue create mode 100644 web/src/views/gas/alarmtype/index.vue create mode 100644 web/src/views/gas/factory/FactoryForm.vue create mode 100644 web/src/views/gas/factory/index.vue create mode 100644 web/src/views/gas/fence/FenceForm.vue create mode 100644 web/src/views/gas/fence/index.vue create mode 100644 web/src/views/gas/fencealarm/FenceAlarmForm.vue create mode 100644 web/src/views/gas/fencealarm/index.vue create mode 100644 web/src/views/gas/gastype/TypeForm.vue create mode 100644 web/src/views/gas/gastype/index.vue create mode 100644 web/src/views/gas/handalarm/HandAlarmForm.vue create mode 100644 web/src/views/gas/handalarm/index.vue create mode 100644 web/src/views/gas/handdetector/HandDetectorForm.vue create mode 100644 web/src/views/gas/handdetector/index.vue diff --git a/readme.md b/readme.md index 0fd9a4b..8370c5e 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,9 @@ # 电子锁 +## 访问地址 + +[手持表后台管理线上地址](https://mobile.zdhlcn.com) + ## tracup [手持表 Tracup 地址](https://www.tracup.com/projects/16e1f1fea4b1aadc5373f3bd908de636/list) diff --git a/web/package.json b/web/package.json index 724d0a2..15ef961 100644 --- a/web/package.json +++ b/web/package.json @@ -60,6 +60,7 @@ "min-dash": "^4.1.1", "mitt": "^3.0.1", "nprogress": "^0.2.0", + "ol": "^10.6.1", "pinia": "^2.1.7", "pinia-plugin-persistedstate": "^3.2.1", "qrcode": "^1.5.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 2a38455..351dba2 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: nprogress: specifier: ^0.2.0 version: 0.2.0 + ol: + specifier: ^10.6.1 + version: 10.6.1 pinia: specifier: ^2.1.7 version: 2.3.1(typescript@5.3.3)(vue@3.5.12(typescript@5.3.3)) @@ -1500,6 +1503,9 @@ packages: resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} engines: {node: '>= 10.0.0'} + '@petamoriken/float16@3.9.2': + resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1976,6 +1982,9 @@ packages: '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + '@types/rbush@4.0.0': + resolution: {integrity: sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -3204,6 +3213,9 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -3543,6 +3555,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + geotiff@2.1.3: + resolution: {integrity: sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==} + engines: {node: '>=10.19'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -3927,6 +3943,9 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + lerc@3.0.0: + resolution: {integrity: sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -4322,6 +4341,9 @@ packages: ofetch@1.4.1: resolution: {integrity: sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==} + ol@10.6.1: + resolution: {integrity: sha512-xp174YOwPeLj7c7/8TCIEHQ4d41tgTDDhdv6SqNdySsql5/MaFJEJkjlsYcvOPt7xA6vrum/QG4UdJ0iCGT1cg==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4371,10 +4393,16 @@ packages: package-manager-detector@1.4.0: resolution: {integrity: sha512-rRZ+pR1Usc+ND9M2NkmCvE/LYJS+8ORVV9X0KuNSY/gFsp7RBHJM/ADh9LYq4Vvfq6QkKrW6/weuh8SMEtN5gw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-headers@2.0.6: + resolution: {integrity: sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -4432,6 +4460,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pbf@4.0.1: + resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==} + hasBin: true + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -4574,6 +4606,9 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + protocol-buffers-schema@3.6.0: + resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -4607,9 +4642,19 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-lru@6.1.2: + resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} + engines: {node: '>=12'} + + quickselect@3.0.0: + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + randomcolor@0.6.2: resolution: {integrity: sha512-Mn6TbyYpFgwFuQ8KJKqf3bqqY9O1y37/0jgSK/61PUxV4QfIMv0+K2ioq8DfOjkBslcjwSzRfIDEXfzA9aCx7A==} + rbush@4.0.1: + resolution: {integrity: sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==} + rd@2.0.1: resolution: {integrity: sha512-/XdKU4UazUZTXFmI0dpABt8jSXPWcEyaGdk340KdHnsEOdkTctlX23aAK7ChQDn39YGNlAJr1M5uvaKt4QnpNw==} @@ -4675,6 +4720,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + resolve@1.22.10: resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} engines: {node: '>= 0.4'} @@ -5324,6 +5372,9 @@ packages: web-storage-cache@1.1.1: resolution: {integrity: sha512-D0MieGooOs8RpsrK+vnejXnvh4OOv/+lTFB35JRkJJQt+uOjPE08XpaE0QBLMTRu47B1KGT/Nq3Gbag3Orinzw==} + web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -5383,6 +5434,9 @@ packages: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + xml-utils@1.10.2: + resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==} + y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -5432,6 +5486,9 @@ packages: zrender@5.6.1: resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + zstddec@0.1.0: + resolution: {integrity: sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==} + snapshots: '@ampproject/remapping@2.3.0': @@ -6811,6 +6868,8 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.1 optional: true + '@petamoriken/float16@3.9.2': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -7191,6 +7250,8 @@ snapshots: '@types/qs@6.14.0': {} + '@types/rbush@4.0.0': {} + '@types/semver@7.7.1': {} '@types/trusted-types@2.0.7': @@ -8742,6 +8803,8 @@ snapshots: duplexer@0.1.2: {} + earcut@3.0.2: {} + eastasianwidth@0.2.0: {} echarts-wordcloud@2.1.0(echarts@5.6.0): @@ -9187,6 +9250,17 @@ snapshots: gensync@1.0.0-beta.2: {} + geotiff@2.1.3: + dependencies: + '@petamoriken/float16': 3.9.2 + lerc: 3.0.0 + pako: 2.1.0 + parse-headers: 2.0.6 + quick-lru: 6.1.2 + web-worker: 1.5.0 + xml-utils: 1.10.2 + zstddec: 0.1.0 + get-caller-file@2.0.5: {} get-east-asian-width@1.4.0: {} @@ -9527,6 +9601,8 @@ snapshots: kolorist@1.8.0: {} + lerc@3.0.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -9914,6 +9990,14 @@ snapshots: node-fetch-native: 1.6.7 ufo: 1.6.1 + ol@10.6.1: + dependencies: + '@types/rbush': 4.0.0 + earcut: 3.0.2 + geotiff: 2.1.3 + pbf: 4.0.1 + rbush: 4.0.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -9965,10 +10049,14 @@ snapshots: package-manager-detector@1.4.0: {} + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 + parse-headers@2.0.6: {} + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.27.1 @@ -10014,6 +10102,10 @@ snapshots: pathe@2.0.3: {} + pbf@4.0.1: + dependencies: + resolve-protobuf-schema: 2.1.0 + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -10143,6 +10235,8 @@ snapshots: proto-list@1.2.4: {} + protocol-buffers-schema@3.6.0: {} + proxy-from-env@1.1.0: {} punycode.js@2.3.1: {} @@ -10169,8 +10263,16 @@ snapshots: queue-microtask@1.2.3: {} + quick-lru@6.1.2: {} + + quickselect@3.0.0: {} + randomcolor@0.6.2: {} + rbush@4.0.1: + dependencies: + quickselect: 3.0.0 + rd@2.0.1: dependencies: '@types/node': 10.17.60 @@ -10225,6 +10327,10 @@ snapshots: resolve-from@5.0.0: {} + resolve-protobuf-schema@2.1.0: + dependencies: + protocol-buffers-schema: 3.6.0 + resolve@1.22.10: dependencies: is-core-module: 2.16.1 @@ -10982,6 +11088,8 @@ snapshots: web-storage-cache@1.1.1: {} + web-worker@1.5.0: {} + webidl-conversions@3.0.1: {} webpack-virtual-modules@0.6.2: {} @@ -11042,6 +11150,8 @@ snapshots: xml-name-validator@4.0.0: {} + xml-utils@1.10.2: {} + y18n@4.0.3: {} y18n@5.0.8: {} @@ -11095,3 +11205,5 @@ snapshots: zrender@5.6.1: dependencies: tslib: 2.3.0 + + zstddec@0.1.0: {} diff --git a/web/src/api/gas/alarmrule/index.ts b/web/src/api/gas/alarmrule/index.ts new file mode 100644 index 0000000..ef7bc22 --- /dev/null +++ b/web/src/api/gas/alarmrule/index.ts @@ -0,0 +1,56 @@ +import request from '@/config/axios' +import type { Dayjs } from 'dayjs' + +/** GAS警报规则信息 */ +export interface AlarmRule { + id: number // 主键ID + gasTypeId?: number // 气体类型ID + alarmTypeId?: number // 警报类型ID + alarmName: string // 警报名称 + alarmNameColor: string // 警报名称颜色 + alarmColor: string // 警报颜色 + alarmLevel: number // 警报方式/级别(0:正常状态;1:一级警报;2:二级警报;3:弹窗警报) + min: number // 触发值(小) + max: number // 触发值(大) + direction?: number // 最值方向(0:小;1:大) + sortOrder?: number // 排序 + remark: string // 备注 +} + +// GAS警报规则 API +export const AlarmRuleApi = { + // 查询GAS警报规则分页 + getAlarmRulePage: async (params: any) => { + return await request.get({ url: `/gas/alarm-rule/page`, params }) + }, + + // 查询GAS警报规则详情 + getAlarmRule: async (id: number) => { + return await request.get({ url: `/gas/alarm-rule/get?id=` + id }) + }, + + // 新增GAS警报规则 + createAlarmRule: async (data: AlarmRule) => { + return await request.post({ url: `/gas/alarm-rule/create`, data }) + }, + + // 修改GAS警报规则 + updateAlarmRule: async (data: AlarmRule) => { + return await request.put({ url: `/gas/alarm-rule/update`, data }) + }, + + // 删除GAS警报规则 + deleteAlarmRule: async (id: number) => { + return await request.delete({ url: `/gas/alarm-rule/delete?id=` + id }) + }, + + /** 批量删除GAS警报规则 */ + deleteAlarmRuleList: async (ids: number[]) => { + return await request.delete({ url: `/gas/alarm-rule/delete-list?ids=${ids.join(',')}` }) + }, + + // 导出GAS警报规则 Excel + exportAlarmRule: async (params) => { + return await request.download({ url: `/gas/alarm-rule/export-excel`, params }) + } +} diff --git a/web/src/api/gas/alarmtype/index.ts b/web/src/api/gas/alarmtype/index.ts new file mode 100644 index 0000000..57701e4 --- /dev/null +++ b/web/src/api/gas/alarmtype/index.ts @@ -0,0 +1,51 @@ +import request from '@/config/axios' +import type { Dayjs } from 'dayjs' + +/** GAS警报类型信息 */ +export interface AlarmType { + id: number // 主键ID + name?: string // 名称 + nameColor: string // 名称颜色 + color?: string // 颜色 + level?: number // 警报方式/级别(0:正常状态;1:一级警报;2:二级警报;3:弹窗警报) + sortOrder?: number // 排序 + remark: string // 备注 +} + +// GAS警报类型 API +export const AlarmTypeApi = { + // 查询GAS警报类型分页 + getAlarmTypePage: async (params: any) => { + return await request.get({ url: `/gas/alarm-type/page`, params }) + }, + + // 查询GAS警报类型详情 + getAlarmType: async (id: number) => { + return await request.get({ url: `/gas/alarm-type/get?id=` + id }) + }, + + // 新增GAS警报类型 + createAlarmType: async (data: AlarmType) => { + return await request.post({ url: `/gas/alarm-type/create`, data }) + }, + + // 修改GAS警报类型 + updateAlarmType: async (data: AlarmType) => { + return await request.put({ url: `/gas/alarm-type/update`, data }) + }, + + // 删除GAS警报类型 + deleteAlarmType: async (id: number) => { + return await request.delete({ url: `/gas/alarm-type/delete?id=` + id }) + }, + + /** 批量删除GAS警报类型 */ + deleteAlarmTypeList: async (ids: number[]) => { + return await request.delete({ url: `/gas/alarm-type/delete-list?ids=${ids.join(',')}` }) + }, + + // 导出GAS警报类型 Excel + exportAlarmType: async (params) => { + return await request.download({ url: `/gas/alarm-type/export-excel`, params }) + } +} diff --git a/web/src/api/gas/factory/index.ts b/web/src/api/gas/factory/index.ts new file mode 100644 index 0000000..842c910 --- /dev/null +++ b/web/src/api/gas/factory/index.ts @@ -0,0 +1,64 @@ +import request from '@/config/axios' +import type { Dayjs } from 'dayjs' + +/** GAS工厂信息 */ +export interface Factory { + id: number // 主键ID + parentId?: number // 父节点ID + type: number // 层级(1:工厂;2:车间;3:班组) + name?: string // 名称 + city: string // 城市 + alarmTotal?: number // 总警报数 + alarmDeal?: number // 已处理警报数 + picUrl: string // 区域图 + picScale: number // 区域图缩放比例 + picX: number // 在区域图X坐标值 + picY: number // 在区域图X坐标值 + longitude: number // 经度 + latitude: number // 纬度 + rectSouthWest: string // 区域西南坐标 + rectNorthEast: string // 区域东北坐标 + sortOrder?: number // 排序 + remark: string // 备注 + delFlag?: number // 删除标志 + createBy?: string // 创建者 + updateBy: string // 更新者 +} + +// GAS工厂 API +export const FactoryApi = { + // 查询GAS工厂分页 + getFactoryPage: async (params: any) => { + return await request.get({ url: `/gas/factory/page`, params }) + }, + + // 查询GAS工厂详情 + getFactory: async (id: number) => { + return await request.get({ url: `/gas/factory/get?id=` + id }) + }, + + // 新增GAS工厂 + createFactory: async (data: Factory) => { + return await request.post({ url: `/gas/factory/create`, data }) + }, + + // 修改GAS工厂 + updateFactory: async (data: Factory) => { + return await request.put({ url: `/gas/factory/update`, data }) + }, + + // 删除GAS工厂 + deleteFactory: async (id: number) => { + return await request.delete({ url: `/gas/factory/delete?id=` + id }) + }, + + /** 批量删除GAS工厂 */ + deleteFactoryList: async (ids: number[]) => { + return await request.delete({ url: `/gas/factory/delete-list?ids=${ids.join(',')}` }) + }, + + // 导出GAS工厂 Excel + exportFactory: async (params) => { + return await request.download({ url: `/gas/factory/export-excel`, params }) + } +} diff --git a/web/src/api/gas/fence/index.ts b/web/src/api/gas/fence/index.ts new file mode 100644 index 0000000..3039038 --- /dev/null +++ b/web/src/api/gas/fence/index.ts @@ -0,0 +1,50 @@ +import request from '@/config/axios' +import type { Dayjs } from 'dayjs' + +/** GAS电子围栏信息 */ +export interface Fence { + id: number // 主键ID + name: string // 围栏名称 + fenceRange: string // 围栏范围 + status: number // 状态(1启用,2禁用) + type: number // 围栏类型(1:超出报警,2:进入报警) + remark: string // 备注 +} + +// GAS电子围栏 API +export const FenceApi = { + // 查询GAS电子围栏分页 + getFencePage: async (params: any) => { + return await request.get({ url: `/gas/fence/page`, params }) + }, + + // 查询GAS电子围栏详情 + getFence: async (id: number) => { + return await request.get({ url: `/gas/fence/get?id=` + id }) + }, + + // 新增GAS电子围栏 + createFence: async (data: Fence) => { + return await request.post({ url: `/gas/fence/create`, data }) + }, + + // 修改GAS电子围栏 + updateFence: async (data: Fence) => { + return await request.put({ url: `/gas/fence/update`, data }) + }, + + // 删除GAS电子围栏 + deleteFence: async (id: number) => { + return await request.delete({ url: `/gas/fence/delete?id=` + id }) + }, + + /** 批量删除GAS电子围栏 */ + deleteFenceList: async (ids: number[]) => { + return await request.delete({ url: `/gas/fence/delete-list?ids=${ids.join(',')}` }) + }, + + // 导出GAS电子围栏 Excel + exportFence: async (params) => { + return await request.download({ url: `/gas/fence/export-excel`, params }) + } +} diff --git a/web/src/api/gas/fencealarm/index.ts b/web/src/api/gas/fencealarm/index.ts new file mode 100644 index 0000000..24c5004 --- /dev/null +++ b/web/src/api/gas/fencealarm/index.ts @@ -0,0 +1,56 @@ +import request from '@/config/axios' +import type { Dayjs } from 'dayjs' + +/** GAS手持探测器围栏报警信息 */ +export interface FenceAlarm { + id: number // 主键ID + detectorId?: number // 探头ID + fenceId: number // 围栏id + type: number // 报警类型 + picX: number // 在区域图X坐标值 + picY: number // 在区域图X坐标值 + distance: number // 超出围栏米数 + maxDistance: number // 最远超出米数 + tAlarmStart: string | Dayjs // 开始时间 + tAlarmEnd: string | Dayjs // 结束时间 + status: number // 状态(0:待处理;1:处理中;1:已处理) + remark: string // 备注 +} + +// GAS手持探测器围栏报警 API +export const FenceAlarmApi = { + // 查询GAS手持探测器围栏报警分页 + getFenceAlarmPage: async (params: any) => { + return await request.get({ url: `/gas/fence-alarm/page`, params }) + }, + + // 查询GAS手持探测器围栏报警详情 + getFenceAlarm: async (id: number) => { + return await request.get({ url: `/gas/fence-alarm/get?id=` + id }) + }, + + // 新增GAS手持探测器围栏报警 + createFenceAlarm: async (data: FenceAlarm) => { + return await request.post({ url: `/gas/fence-alarm/create`, data }) + }, + + // 修改GAS手持探测器围栏报警 + updateFenceAlarm: async (data: FenceAlarm) => { + return await request.put({ url: `/gas/fence-alarm/update`, data }) + }, + + // 删除GAS手持探测器围栏报警 + deleteFenceAlarm: async (id: number) => { + return await request.delete({ url: `/gas/fence-alarm/delete?id=` + id }) + }, + + /** 批量删除GAS手持探测器围栏报警 */ + deleteFenceAlarmList: async (ids: number[]) => { + return await request.delete({ url: `/gas/fence-alarm/delete-list?ids=${ids.join(',')}` }) + }, + + // 导出GAS手持探测器围栏报警 Excel + exportFenceAlarm: async (params) => { + return await request.download({ url: `/gas/fence-alarm/export-excel`, params }) + } +} diff --git a/web/src/api/gas/gastype/index.ts b/web/src/api/gas/gastype/index.ts new file mode 100644 index 0000000..f23c3c4 --- /dev/null +++ b/web/src/api/gas/gastype/index.ts @@ -0,0 +1,50 @@ +import request from '@/config/axios' +import type { Dayjs } from 'dayjs' + +/** GAS气体信息 */ +export interface Type { + id: number // 主键ID + name?: string // 名称 + chemical?: string // 化学式 + unit: string // 单位 + sortOrder?: number // 排序 + remark: string // 备注 +} + +// GAS气体 API +export const TypeApi = { + // 查询GAS气体分页 + getTypePage: async (params: any) => { + return await request.get({ url: `/gas/type/page`, params }) + }, + + // 查询GAS气体详情 + getType: async (id: number) => { + return await request.get({ url: `/gas/type/get?id=` + id }) + }, + + // 新增GAS气体 + createType: async (data: Type) => { + return await request.post({ url: `/gas/type/create`, data }) + }, + + // 修改GAS气体 + updateType: async (data: Type) => { + return await request.put({ url: `/gas/type/update`, data }) + }, + + // 删除GAS气体 + deleteType: async (id: number) => { + return await request.delete({ url: `/gas/type/delete?id=` + id }) + }, + + /** 批量删除GAS气体 */ + deleteTypeList: async (ids: number[]) => { + return await request.delete({ url: `/gas/type/delete-list?ids=${ids.join(',')}` }) + }, + + // 导出GAS气体 Excel + exportType: async (params) => { + return await request.download({ url: `/gas/type/export-excel`, params }) + } +} diff --git a/web/src/api/gas/handalarm/index.ts b/web/src/api/gas/handalarm/index.ts new file mode 100644 index 0000000..3b31724 --- /dev/null +++ b/web/src/api/gas/handalarm/index.ts @@ -0,0 +1,60 @@ +import request from '@/config/axios' +import type { Dayjs } from 'dayjs' + +/** GAS手持探测器警报信息 */ +export interface HandAlarm { + id: number // 主键ID + detectorId: number // 手持表id + sn: string // 设备编号 + alarmType: number // 报警类型 + alarmLevel?: number // 警报方式/级别(0:正常状态;1:一级警报;2:二级警报;3:弹窗警报) + gasType?: string // 气体类型 + unit?: string // 单位 + location: string // 位置描述 + picX: number // 在区域图X坐标值 + picY?: number // 在区域图X坐标值 + vAlarmFirst: number // 首报值 + vAlarmMaximum: number // 最值 + tAlarmStart: string | Dayjs // 开始时间 + tAlarmEnd: string | Dayjs // 结束时间 + status: number // 状态(0:待处理;1:处理中;1:已处理) + remark?: string // 备注 +} + +// GAS手持探测器警报 API +export const HandAlarmApi = { + // 查询GAS手持探测器警报分页 + getHandAlarmPage: async (params: any) => { + return await request.get({ url: `/gas/hand-alarm/page`, params }) + }, + + // 查询GAS手持探测器警报详情 + getHandAlarm: async (id: number) => { + return await request.get({ url: `/gas/hand-alarm/get?id=` + id }) + }, + + // 新增GAS手持探测器警报 + createHandAlarm: async (data: HandAlarm) => { + return await request.post({ url: `/gas/hand-alarm/create`, data }) + }, + + // 修改GAS手持探测器警报 + updateHandAlarm: async (data: HandAlarm) => { + return await request.put({ url: `/gas/hand-alarm/update`, data }) + }, + + // 删除GAS手持探测器警报 + deleteHandAlarm: async (id: number) => { + return await request.delete({ url: `/gas/hand-alarm/delete?id=` + id }) + }, + + /** 批量删除GAS手持探测器警报 */ + deleteHandAlarmList: async (ids: number[]) => { + return await request.delete({ url: `/gas/hand-alarm/delete-list?ids=${ids.join(',')}` }) + }, + + // 导出GAS手持探测器警报 Excel + exportHandAlarm: async (params) => { + return await request.download({ url: `/gas/hand-alarm/export-excel`, params }) + } +} diff --git a/web/src/api/gas/handdetector/index.ts b/web/src/api/gas/handdetector/index.ts new file mode 100644 index 0000000..7f23fd5 --- /dev/null +++ b/web/src/api/gas/handdetector/index.ts @@ -0,0 +1,63 @@ +import request from '@/config/axios' +import type { Dayjs } from 'dayjs' + +/** GAS手持探测器信息 */ +export interface HandDetector { + id: number // 主键ID + sn?: string // SN + name?: string // 持有人 + fenceIds?: string // 围栏ids + fenceIdsArray?: string[] // 围栏ids数组 + gasTypeId?: number // 气体类型ID + gasChemical?: string // 气体化学式 + min?: number // 测量范围中的最小值 + max?: number // 测量范围中的最大值 + unit?: string // 单位 + model?: string // 设备型号 + manufacturer?: string // 生产厂家 + batteryAlarmValue?: number // 低于多少电量报警 + enableStatus?: number // 启用状态(0:备用;1:启用) + longitude?: number // 经度 + latitude?: number // 纬度 + accuracy?: number // 数值除数 + sortOrder?: number // 排序 + remark?: string // 备注 +} + +// GAS手持探测器 API +export const HandDetectorApi = { + // 查询GAS手持探测器分页 + getHandDetectorPage: async (params: any) => { + return await request.get({ url: `/gas/hand-detector/page`, params }) + }, + + // 查询GAS手持探测器详情 + getHandDetector: async (id: number) => { + return await request.get({ url: `/gas/hand-detector/get?id=` + id }) + }, + + // 新增GAS手持探测器 + createHandDetector: async (data: HandDetector) => { + return await request.post({ url: `/gas/hand-detector/create`, data }) + }, + + // 修改GAS手持探测器 + updateHandDetector: async (data: HandDetector) => { + return await request.put({ url: `/gas/hand-detector/update`, data }) + }, + + // 删除GAS手持探测器 + deleteHandDetector: async (id: number) => { + return await request.delete({ url: `/gas/hand-detector/delete?id=` + id }) + }, + + /** 批量删除GAS手持探测器 */ + deleteHandDetectorList: async (ids: number[]) => { + return await request.delete({ url: `/gas/hand-detector/delete-list?ids=${ids.join(',')}` }) + }, + + // 导出GAS手持探测器 Excel + exportHandDetector: async (params) => { + return await request.download({ url: `/gas/hand-detector/export-excel`, params }) + } +} diff --git a/web/src/api/gas/index.ts b/web/src/api/gas/index.ts new file mode 100644 index 0000000..d830fdb --- /dev/null +++ b/web/src/api/gas/index.ts @@ -0,0 +1,7 @@ +import request from '@/config/axios' + +const getLastestDetectorData = async () => { + const data = await request.get({ url: `/gas/hand-detector/getByHandData` }) + return Object.values(data) +} +export { getLastestDetectorData } diff --git a/web/src/layout/components/Footer/src/Footer.vue b/web/src/layout/components/Footer/src/Footer.vue index 2ea2e80..d5e5a4e 100644 --- a/web/src/layout/components/Footer/src/Footer.vue +++ b/web/src/layout/components/Footer/src/Footer.vue @@ -26,7 +26,9 @@ const appVersion = __APP_VERSION__ class="h-[var(--app-footer-height)] bg-[var(--app-content-bg-color)] text-center leading-[var(--app-footer-height)] text-[var(--el-text-color-placeholder)] dark:bg-[var(--el-bg-color)] overflow-hidden" > Copyright ©{{ currentYear }} {{ title }} 京公网安备 11010702002311号 Version:{{ appVersion }} + >Copyright ©{{ currentYear }} {{ title }} 京公网安备 11010702002311号 Version:{{ + appVersion + }} diff --git a/web/src/locales/zh-CN.ts b/web/src/locales/zh-CN.ts index f74b278..b5e1f0c 100644 --- a/web/src/locales/zh-CN.ts +++ b/web/src/locales/zh-CN.ts @@ -155,7 +155,7 @@ export default { }, router: { login: '登录', - home: '首页', + home: '综合监控', analysis: '分析页', workplace: '工作台' }, diff --git a/web/src/main.ts b/web/src/main.ts index 905c032..811ed3d 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -21,6 +21,7 @@ import { setupFormCreate } from '@/plugins/formCreate' // 引入全局样式 import '@/styles/index.scss' +import 'ol/ol.css' // 引入动画 import '@/plugins/animate.css' diff --git a/web/src/store/modules/app.ts b/web/src/store/modules/app.ts index a4d752a..2a6a9fc 100644 --- a/web/src/store/modules/app.ts +++ b/web/src/store/modules/app.ts @@ -64,8 +64,8 @@ export const useAppStore = defineStore('app', { size: false, // 尺寸图标 locale: false, // 多语言图标 message: false, // 消息图标 - tagsView: false, // 标签页 - tagsViewImmerse: false, // 标签页沉浸 + tagsView: true, // 标签页 + tagsViewImmerse: true, // 标签页沉浸 tagsViewIcon: true, // 是否显示标签图标 logo: true, // logo fixedHeader: true, // 固定toolheader diff --git a/web/src/store/modules/handDetector.ts b/web/src/store/modules/handDetector.ts new file mode 100644 index 0000000..bfa1d69 --- /dev/null +++ b/web/src/store/modules/handDetector.ts @@ -0,0 +1,81 @@ +import { defineStore } from 'pinia' +import { store } from '@/store' +import { HandDetector, HandDetectorApi } from '@/api/gas/handdetector' +import { Type, TypeApi } from '@/api/gas/gastype' +import { Fence, FenceApi } from '@/api/gas/fence' +import { AlarmType, AlarmTypeApi } from '@/api/gas/alarmtype' + +export const useHandDetectorStore = defineStore('handDetector', { + state() { + return { + handDetectorList: [] as HandDetector[], + gasTypes: [] as Type[], + fences: [] as Fence[], + alarmTypes: [] as AlarmType[] + } + }, + getters: { + getHandDetectorList(): HandDetector[] { + return this.handDetectorList + }, + getGasTypes(): Type[] { + return this.gasTypes + }, + getFences(): Fence[] { + return this.fences + }, + getAlarmTypes(): AlarmType[] { + return this.alarmTypes + } + }, + actions: { + async getAllHandDetector(refresh: boolean = false) { + if (refresh || this.handDetectorList.length === 0) { + const data = await HandDetectorApi.getHandDetectorPage({ + pageNo: 1, + pageSize: 100 + }) + this.handDetectorList = data.list + return this.handDetectorList + } else { + return this.handDetectorList + } + }, + async getAllFences(refresh: boolean = false) { + if (refresh || this.fences.length === 0) { + const data = await FenceApi.getFencePage({ + pageNo: 1, + pageSize: 100 + }) + this.fences = data.list + return this.fences + } else { + return this.fences + } + }, + async getAllGasTypes(refresh: boolean = false) { + if (refresh || this.gasTypes.length === 0) { + const data = await TypeApi.getTypePage({ + pageNo: 1, + pageSize: 100 + }) + this.gasTypes = data.list + return this.gasTypes + } else { + return this.gasTypes + } + }, + async getAllAlarmTypes(refresh: boolean = false) { + if (refresh || this.alarmTypes.length === 0) { + const data = await AlarmTypeApi.getAlarmTypePage({ + pageNo: 1, + pageSize: 100 + }) + this.alarmTypes = data.list + return this.alarmTypes + } else { + return this.alarmTypes + } + } + } +}) diff --git a/web/src/utils/dict.ts b/web/src/utils/dict.ts index 7582a0b..94e9e32 100644 --- a/web/src/utils/dict.ts +++ b/web/src/utils/dict.ts @@ -167,5 +167,13 @@ export enum DICT_TYPE { IOT_PLUGIN_STATUS = 'iot_plugin_status', // IOT 插件状态 IOT_PLUGIN_TYPE = 'iot_plugin_type', // IOT 插件类型 IOT_DATA_BRIDGE_DIRECTION_ENUM = 'iot_data_bridge_direction_enum', // 桥梁方向 - IOT_DATA_BRIDGE_TYPE_ENUM = 'iot_data_bridge_type_enum' // 桥梁类型 + IOT_DATA_BRIDGE_TYPE_ENUM = 'iot_data_bridge_type_enum', // 桥梁类型 + + // ========== HAND 手持表模块 ========== + HAND_DETECTOR_ENABLE_STATUS = 'hand_detector_enable_status', // HAND 手持探测器启用状态 0:备用;1:启用 + HAND_DETECTOR_FENCE_TYPE = 'hand_detector_fence_type', // HAND 手持探测器围栏类型 1:超出报警;2:进入报警 + HAND_DETECTOR_FENCE_STATUS = 'hand_detector_fence_status', // HAND 手持探测器围栏状态 1:启用;2:禁用 + HAND_DETECTOR_ALARM_LEVEL = 'hand_detector_alarm_level', // HAND 手持探测器警报方式/级别 0:正常状态;1:一级警报;2:二级警报;3:弹窗警报 + HAND_DETECTOR_HANDLE_STATUS = 'hand_detector_handle_status', // HAND 手持探测器处理状态 0:待处理;1:处理中;1:已处理 + HAND_DETECTOR_VALUE_DIRECTION = 'hand_detector_value_direction' // HAND 手持探测器最值方向 0:小;1:大 } diff --git a/web/src/views/HandDevice/History/index.vue b/web/src/views/HandDevice/History/index.vue new file mode 100644 index 0000000..40975ee --- /dev/null +++ b/web/src/views/HandDevice/History/index.vue @@ -0,0 +1,4 @@ + + diff --git a/web/src/views/HandDevice/Home/components/MapControls.vue b/web/src/views/HandDevice/Home/components/MapControls.vue new file mode 100644 index 0000000..ebec43f --- /dev/null +++ b/web/src/views/HandDevice/Home/components/MapControls.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/web/src/views/HandDevice/Home/components/OpenLayerMap.vue b/web/src/views/HandDevice/Home/components/OpenLayerMap.vue new file mode 100644 index 0000000..e8f2615 --- /dev/null +++ b/web/src/views/HandDevice/Home/components/OpenLayerMap.vue @@ -0,0 +1,510 @@ + + + diff --git a/web/src/views/HandDevice/Home/components/TrajectoryControls.vue b/web/src/views/HandDevice/Home/components/TrajectoryControls.vue new file mode 100644 index 0000000..70ac130 --- /dev/null +++ b/web/src/views/HandDevice/Home/components/TrajectoryControls.vue @@ -0,0 +1,526 @@ + + + + diff --git a/web/src/views/HandDevice/Home/components/composables/useMapEvents.ts b/web/src/views/HandDevice/Home/components/composables/useMapEvents.ts new file mode 100644 index 0000000..c574225 --- /dev/null +++ b/web/src/views/HandDevice/Home/components/composables/useMapEvents.ts @@ -0,0 +1,273 @@ +/** + * 地图事件处理相关的 composable + */ +import dayjs from 'dayjs' +import { fromLonLat } from 'ol/proj' +import { TrajectoryService } from '../services/trajectory.service' +import { PopupService } from '../services/popup.service' + +interface PopupContentGenerator { + handleTrajectoryPoint: (feature: any) => string + handleTrajectoryLine: (feature: any) => string + handleFence: (feature: any) => string + handleMarker: (feature: any) => string +} + +export const useMapEvents = () => { + // 创建弹窗内容生成器 + const createPopupContentGenerator = ( + trajectoryService: any, + popupService: any + ): PopupContentGenerator => ({ + handleTrajectoryPoint: (feature: any): string => { + const timeText = feature.get('timeText') || '' + const trajectoryId = feature.get('trajectoryId') || '' + const timestamp = feature.get('timestamp') + const deviceName = + trajectoryService?.getTrajectoryData().find((t) => t.deviceId === trajectoryId)?.name || + trajectoryId + + return ` +
+
${deviceName}
+
时间: ${timeText}
+
+ ${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')} +
+
+ ` + }, + + handleTrajectoryLine: (feature: any): string => { + const deviceId = feature.get('deviceId') || '' + const deviceName = + trajectoryService?.getTrajectoryData().find((t) => t.deviceId === deviceId)?.name || + deviceId + + return ` +
+
${deviceName} - 轨迹路径
+
+ ` + }, + + handleFence: (feature: any): string => { + const fenceData = feature.get('fenceData') + const statusText = + fenceData.status === 0 ? '正常' : fenceData.status === 1 ? '一级报警' : '二级报警' + const typeText = fenceData.type === 0 ? '包含' : '排斥' + + return ` +
+
${fenceData.name}
+
状态: ${statusText}
+
类型: ${typeText}
+
+ ${fenceData.remark || '无备注'} +
+
+ ` + }, + + handleMarker: (feature: any): string => { + return popupService?.handlePopupContent(feature) || '' + } + }) + + /** + * 设置地图事件监听器 + */ + const setupMapEventListeners = ( + map: any, + popupOverlay: any, + trajectoryService: TrajectoryService | null, + popupService: PopupService | null, + opts?: { + isDrawing?: () => boolean + onMarkerClick?: (markerData: any) => void + markerLayer?: any + refreshMarkerStyles?: () => void + } + ) => { + const popupGenerator = createPopupContentGenerator(trajectoryService, popupService) + + // 鼠标悬停事件 + const handlePointerMove = (event: any) => { + // 绘制围栏时屏蔽 hover 弹窗 + if (opts?.isDrawing && opts.isDrawing()) { + hidePopup(popupOverlay) + return + } + const feature = map.forEachFeatureAtPixel(event.pixel, (feature: any) => feature) + + if (feature) { + map.getTargetElement().style.cursor = 'pointer' + showPopup(event, feature, popupOverlay, popupGenerator) + } else { + map.getTargetElement().style.cursor = '' + hidePopup(popupOverlay) + } + } + + // 点击事件 + const handleClick = (event: any) => { + // 绘制围栏时屏蔽点击处理 + if (opts?.isDrawing && opts.isDrawing()) { + return + } + const feature = map.forEachFeatureAtPixel(event.pixel, (feature: any) => feature) + if (feature) { + handleFeatureClick(feature, map, opts) + } + } + + // 地图移动结束事件(包括放缩) + const handleMoveEnd = () => { + // OpenLayers的Cluster会自动重新计算聚合,只需要刷新样式 + if (opts?.markerLayer) { + opts.markerLayer.changed() + } + } + + map.on('pointermove', handlePointerMove) + map.on('click', handleClick) + map.on('moveend', handleMoveEnd) + + return { + destroy: () => { + map.un('pointermove', handlePointerMove) + map.un('click', handleClick) + map.un('moveend', handleMoveEnd) + } + } + } + + /** + * 显示弹窗 + */ + const showPopup = ( + event: any, + feature: any, + popupOverlay: any, + popupGenerator: PopupContentGenerator + ) => { + if (!popupOverlay) return + + const popupElement = popupOverlay.getElement() + if (!popupElement) return + + const featureType = feature.get('type') + let popupContent = '' + + switch (featureType) { + case 'trajectory-point': + popupContent = popupGenerator.handleTrajectoryPoint(feature) + break + case 'trajectory': + popupContent = popupGenerator.handleTrajectoryLine(feature) + break + case 'fence': + case 'fence-label': + popupContent = popupGenerator.handleFence(feature) + break + default: + popupContent = popupGenerator.handleMarker(feature) + break + } + + popupElement.innerHTML = popupContent + popupOverlay.setPosition(event.coordinate) + } + + /** + * 隐藏弹窗 + */ + const hidePopup = (popupOverlay: any) => { + if (popupOverlay) { + popupOverlay.setPosition(undefined) + } + } + + /** + * 处理特征点击事件 + */ + const handleFeatureClick = ( + feature: any, + map: any, + opts?: { onMarkerClick?: (markerData: any) => void } + ) => { + const featureType = feature.get('type') + + // 处理围栏点击 + if (featureType === 'fence' || featureType === 'fence-label') { + const fenceData = feature.get('fenceData') + if (fenceData) { + console.log('围栏点击:', fenceData) + // 可以在这里添加围栏点击的自定义处理逻辑 + } + return + } + + // 处理标记点击 + handleMarkerClick(feature, map, opts) + } + + /** + * 处理标记点击事件 + */ + const handleMarkerClick = ( + feature: any, + map: any, + opts?: { onMarkerClick?: (markerData: any) => void } + ) => { + const markerData = feature.get('markerData') + const features = feature.get('features') + + if (features && features.length > 1) { + // 处理聚合标记点击 + handleClusterClick(features, map) + } else if (features && features.length === 1) { + // 处理聚合中的单个标记点击 + const singleMarkerData = features[0].get('markerData') + if (singleMarkerData) { + animateToCoordinate(singleMarkerData.coordinates, map, 15) + opts?.onMarkerClick?.(singleMarkerData) + } + } else if (markerData) { + // 处理非聚合的单个标记点击 + animateToCoordinate(markerData.coordinates, map, 15) + opts?.onMarkerClick?.(markerData) + } + } + + /** + * 处理聚合标记点击 + */ + const handleClusterClick = (features: any[], map: any) => { + // 计算聚合标记的中心点 + const coordinates = features.map((f: any) => f.get('markerData').coordinates) + const centerLon = + coordinates.reduce((sum: number, coord: any) => sum + coord[0], 0) / coordinates.length + const centerLat = + coordinates.reduce((sum: number, coord: any) => sum + coord[1], 0) / coordinates.length + + animateToCoordinate([centerLon, centerLat], map, 12) + } + + /** + * 动画移动到指定坐标 + */ + const animateToCoordinate = (coordinates: [number, number], map: any, zoom: number) => { + const view = map.getView() + + view.animate({ + center: fromLonLat(coordinates), + zoom: Math.max(view.getZoom() || 10, zoom), + duration: 1000 + }) + } + + return { + setupMapEventListeners + } +} diff --git a/web/src/views/HandDevice/Home/components/composables/useMapServices.ts b/web/src/views/HandDevice/Home/components/composables/useMapServices.ts new file mode 100644 index 0000000..8b868d6 --- /dev/null +++ b/web/src/views/HandDevice/Home/components/composables/useMapServices.ts @@ -0,0 +1,297 @@ +/** + * 地图服务管理相关的 composable + */ +import { ref, onUnmounted, reactive } from 'vue' +import type { MapProps } from '../types/map.types' +import { MapService } from '../services/map.service' +import { MarkerService } from '../services/marker.service' +import { AnimationService } from '../services/animation.service' +import { PopupService } from '../services/popup.service' +import { TrajectoryService } from '../services/trajectory.service' +import { FenceService } from '../services/fence.service' +import { FenceDrawService } from '../services/fence-draw.service' + +interface ServiceInstances { + mapService: MapService | null + markerService: MarkerService | null + animationService: AnimationService | null + popupService: PopupService | null + trajectoryService: TrajectoryService | null + fenceService: FenceService | null + fenceDrawService: FenceDrawService | null +} + +interface LayerRefs { + markerLayer: any + rippleLayer: any + trajectoryLayer: any + fenceLayer: any +} + +export const useMapServices = () => { + // 服务实例状态 + const services = reactive({ + mapService: null, + markerService: null, + animationService: null, + popupService: null, + trajectoryService: null, + fenceService: null, + fenceDrawService: null + }) + + // 图层引用状态 + const layerRefs = ref(null) + + /** + * 初始化所有服务 + */ + const initializeServices = (map?: any) => { + // 就地更新,保持对 services 的引用不变,避免外部拿到旧引用 + services.mapService = new MapService() + services.markerService = new MarkerService() + services.animationService = new AnimationService() + services.popupService = new PopupService() + services.trajectoryService = new TrajectoryService() + services.fenceService = new FenceService() + services.fenceDrawService = new FenceDrawService() + + // 如果提供了地图实例,设置给相关服务 + if (map) { + services.markerService.setMap(map) + } + } + + /** + * 初始化地图和图层 + */ + const initializeMapAndLayers = ( + mapInstance: { map: any; popupOverlay: any }, + props: MapProps + ) => { + // 初始化地图 + const map = mapInstance.map + const popupOverlay = mapInstance.popupOverlay + + // 重新初始化服务,确保markerService有地图实例 + initializeServices(map) + + if ( + !services.markerService || + !services.animationService || + !services.trajectoryService || + !services.fenceService || + !services.fenceDrawService + ) { + throw new Error('Services not initialized') + } + + // 创建各种图层 + const markerLayer = services.markerService.createMarkerLayer(props, map) + const rippleLayer = services.animationService.createRippleLayer( + props.markers || [], + map, + props.enableCluster + ) + const trajectoryLayer = services.trajectoryService.createTrajectoryLayer(map) + const fenceLayer = services.fenceService.createFenceLayer(props.fences || [], map) + + // // 添加图层到地图 + map.addLayer(markerLayer) + map.addLayer(rippleLayer) + map.addLayer(trajectoryLayer) + map.addLayer(fenceLayer) + + // 初始化围栏绘制服务 + services.fenceDrawService.init(map) + + // 存储图层引用 + layerRefs.value = { + markerLayer, + rippleLayer, + trajectoryLayer, + fenceLayer + } + return { + map, + popupOverlay + } + } + + /** + * 设置标记显示状态 + */ + const setMarkersVisible = (visible: boolean) => { + if (!layerRefs.value || !services.animationService) return + + const { markerLayer, rippleLayer } = layerRefs.value + + if (visible) { + markerLayer.setVisible(true) + rippleLayer.setVisible(true) + services.animationService.startAnimation() + } else { + markerLayer.setVisible(false) + rippleLayer.setVisible(false) + services.animationService.stopAnimation() + } + } + + /** + * 设置轨迹显示状态 + */ + const setTrajectoriesVisible = (visible: boolean, markers: any[] = []) => { + if (!services.trajectoryService) return + + if (visible) { + services.trajectoryService.setTrajectoryData(markers) + services.trajectoryService.showTrajectories() + } else { + services.trajectoryService.hideTrajectories() + } + } + + /** + * 设置围栏显示状态 + */ + const setFencesVisible = (visible: boolean) => { + if (!services.fenceService) return + + if (visible) { + services.fenceService.showFences() + } else { + services.fenceService.hideFences() + } + } + + /** + * 开始/停止围栏绘制 + */ + const toggleFenceDrawing = ( + isDrawing: boolean, + onComplete?: (coordinates: [number, number][]) => void + ) => { + if (!services.fenceDrawService) return + + if (isDrawing && onComplete) { + services.fenceDrawService.startDrawing(onComplete) + } else { + services.fenceDrawService.stopDrawing() + } + } + + /** + * 清理围栏绘制图层 + */ + const clearFenceDrawLayer = () => { + if (services.fenceDrawService) { + services.fenceDrawService.clearDrawLayer() + } + } + + /** + * 更新标记数据 + */ + const updateMarkers = (markers: any[], currentProps?: any) => { + if ( + services.markerService && + services.animationService && + layerRefs.value?.markerLayer && + layerRefs.value?.rippleLayer + ) { + const map = services.mapService?.getMap() + const enableCluster = currentProps?.enableCluster ?? true + if (map) { + // 从地图中移除旧的marker layer + map.removeLayer(layerRefs.value.markerLayer) + map.removeLayer(layerRefs.value.rippleLayer) + + // 更新marker service(这会创建新的layer) + services.markerService.updateMarkers(markers) + + // 重新创建波纹图层 + const newRippleLayer = services.animationService.createRippleLayer( + markers, + map, + enableCluster + ) + + // 获取新的layer并添加到地图 + const newMarkerLayer = services.markerService.getMarkerLayer() + if (newMarkerLayer && newRippleLayer) { + // 确保markerService有最新的地图实例 + services.markerService.setMap(map) + map.addLayer(newMarkerLayer) + map.addLayer(newRippleLayer) + layerRefs.value.markerLayer = newMarkerLayer + layerRefs.value.rippleLayer = newRippleLayer + } + } + } + } + + /** + * 刷新标记样式 + */ + const refreshMarkerStyles = () => { + if (services.markerService) { + services.markerService.refreshStyles() + } + } + + /** + * 销毁所有服务 + */ + const destroyServices = () => { + // 销毁有 destroy 方法的服务 + const servicesToDestroy = [ + services.mapService, + services.markerService, + services.animationService, + services.trajectoryService, + services.fenceService, + services.fenceDrawService + ] + + servicesToDestroy.forEach((service) => { + if (service && typeof service.destroy === 'function') { + service.destroy() + } + }) + + // 重置服务字段(保持 reactive 对象引用不变) + services.mapService = null + services.markerService = null + services.animationService = null + services.popupService = null + services.trajectoryService = null + services.fenceService = null + services.fenceDrawService = null + + // 重置图层引用 + layerRefs.value = null + } + + // 组件卸载时自动清理 + onUnmounted(() => { + destroyServices() + }) + + return { + // 状态 + services, + layerRefs, + + // 方法 + initializeServices, + initializeMapAndLayers, + setMarkersVisible, + setTrajectoriesVisible, + setFencesVisible, + toggleFenceDrawing, + clearFenceDrawLayer, + updateMarkers, + refreshMarkerStyles, + destroyServices + } +} diff --git a/web/src/views/HandDevice/Home/components/composables/useMapWatchers.ts b/web/src/views/HandDevice/Home/components/composables/useMapWatchers.ts new file mode 100644 index 0000000..c49bab5 --- /dev/null +++ b/web/src/views/HandDevice/Home/components/composables/useMapWatchers.ts @@ -0,0 +1,139 @@ +/** + * 地图状态监听相关的 composable + */ +import { watch, type Ref } from 'vue' + +interface WatchOptions { + showMarkers: Ref + showTrajectories: Ref + showFences: Ref + showDrawFences: Ref + setMarkersVisible: (visible: boolean) => void + setTrajectoriesVisible: (visible: boolean, markers?: any[]) => void + setFencesVisible: (visible: boolean) => void + toggleFenceDrawing: ( + isDrawing: boolean, + onComplete?: (coordinates: [number, number][]) => void + ) => void + updateMarkers: (markers: any[]) => void + markers: any[] +} + +export const useMapWatchers = (options: WatchOptions) => { + const { + showMarkers, + showTrajectories, + showFences, + showDrawFences, + setMarkersVisible, + setTrajectoriesVisible, + setFencesVisible, + toggleFenceDrawing, + updateMarkers, + markers + } = options + + /** + * 设置标记显示状态监听器 + */ + const setupMarkersWatcher = () => { + return watch(showMarkers, (show) => { + if (show) { + // 显示标记时,隐藏轨迹 + if (showTrajectories.value) { + showTrajectories.value = false + } + } + setMarkersVisible(show) + }) + } + + /** + * 设置轨迹显示状态监听器 + */ + const setupTrajectoriesWatcher = () => { + return watch(showTrajectories, (show) => { + if (show) { + // 显示轨迹时,隐藏标记 + if (showMarkers.value) { + showMarkers.value = false + } + setTrajectoriesVisible(true, markers) + } else { + setTrajectoriesVisible(false) + // 隐藏轨迹时,显示标记 + if (!showMarkers.value) { + showMarkers.value = true + } + } + }) + } + + /** + * 设置围栏显示状态监听器 + */ + const setupFencesWatcher = () => { + return watch(showFences, (show) => { + setFencesVisible(show) + }) + } + + /** + * 设置围栏绘制状态监听器 + */ + const setupDrawFencesWatcher = () => { + return watch(showDrawFences, (isDrawing) => { + if (!isDrawing) { + // 当关闭绘制时,停止绘制操作 + toggleFenceDrawing(false) + } + }) + } + + /** + * 设置标记数据变化监听器 + */ + const setupMarkersDataWatcher = () => { + return watch( + markers, + (newMarkers) => { + if (newMarkers && newMarkers.length > 0) { + console.log('Markers data changed, updating markers:', newMarkers.length) + updateMarkers(newMarkers) + } + }, + { deep: true, immediate: false } + ) + } + + /** + * 初始化所有监听器 + */ + const setupAllWatchers = () => { + const watchers = [ + setupMarkersWatcher(), + setupTrajectoriesWatcher(), + setupFencesWatcher(), + setupDrawFencesWatcher(), + setupMarkersDataWatcher() + ] + + // 返回清理函数 + return () => { + watchers.forEach((stopWatcher) => { + if (typeof stopWatcher === 'function') { + stopWatcher() + } + }) + } + } + + return { + setupMarkersWatcher, + setupTrajectoriesWatcher, + setupFencesWatcher, + setupDrawFencesWatcher, + setupMarkersDataWatcher, + setupAllWatchers + } +} diff --git a/web/src/views/HandDevice/Home/components/composables/useTrajectoryControls.ts b/web/src/views/HandDevice/Home/components/composables/useTrajectoryControls.ts new file mode 100644 index 0000000..cde7e78 --- /dev/null +++ b/web/src/views/HandDevice/Home/components/composables/useTrajectoryControls.ts @@ -0,0 +1,147 @@ +/** + * 轨迹控制相关的 composable + */ +import { ref, watch } from 'vue' +import dayjs from 'dayjs' +import type { TrajectoryPlayState } from '../types/map.types' +import { TrajectoryService } from '../services/trajectory.service' + +export const useTrajectoryControls = () => { + // 轨迹播放状态 + const trajectoryPlayState = ref({ + isPlaying: false, + currentTime: dayjs().subtract(1, 'day').valueOf(), + speed: 1, + startTime: dayjs().subtract(1, 'day').valueOf(), + endTime: dayjs().valueOf() + }) + + // 轨迹播放定时器 + const trajectoryPlayTimer = ref(null) + + /** + * 播放轨迹 + */ + const playTrajectory = () => { + if (trajectoryPlayTimer.value) { + window.clearInterval(trajectoryPlayTimer.value) + } + + trajectoryPlayTimer.value = window.setInterval(() => { + trajectoryPlayState.value.currentTime += 1000 * trajectoryPlayState.value.speed + trajectoryPlayState.value.isPlaying = true + }, 1000) + } + + /** + * 暂停轨迹 + */ + const pauseTrajectory = () => { + if (trajectoryPlayTimer.value) { + window.clearInterval(trajectoryPlayTimer.value) + trajectoryPlayTimer.value = null + trajectoryPlayState.value.isPlaying = false + } + } + + /** + * 停止轨迹 + */ + const stopTrajectory = () => { + if (trajectoryPlayTimer.value) { + window.clearInterval(trajectoryPlayTimer.value) + trajectoryPlayTimer.value = null + trajectoryPlayState.value.isPlaying = false + } + } + + /** + * 设置轨迹播放速度 + */ + const setTrajectorySpeed = (speed: number) => { + trajectoryPlayState.value.speed = speed + + // 如果正在播放,重启定时器以应用新速度 + if (trajectoryPlayTimer.value) { + window.clearInterval(trajectoryPlayTimer.value) + trajectoryPlayTimer.value = window.setInterval(() => { + trajectoryPlayState.value.currentTime += 1000 * trajectoryPlayState.value.speed + }, 1000) + } + } + + /** + * 设置轨迹播放时间 + */ + const setTrajectoryTime = (timestamp: number) => { + trajectoryPlayState.value.currentTime = timestamp + } + + /** + * 设置轨迹时间范围 + */ + const setTrajectoryTimeRange = (range: { startTime: number; endTime: number }) => { + // 停止当前播放 + if (trajectoryPlayTimer.value) { + window.clearInterval(trajectoryPlayTimer.value) + trajectoryPlayTimer.value = null + } + + // 更新轨迹播放状态的时间范围 + trajectoryPlayState.value = { + ...trajectoryPlayState.value, + isPlaying: false, + currentTime: range.startTime, + startTime: range.startTime, + endTime: range.endTime + } + } + + /** + * 设置轨迹播放状态监听器 + */ + const setupTrajectoryWatcher = ( + trajectoryService: TrajectoryService | null, + showTrajectories: { value: boolean } + ) => { + // 监听轨迹播放状态变化 + const stopPlayStateWatcher = watch( + () => trajectoryPlayState.value, + (newState) => { + if (trajectoryService && showTrajectories.value) { + trajectoryService.updateByPlayState(newState) + } + }, + { deep: true } + ) + + return { + stopPlayStateWatcher + } + } + + /** + * 清理轨迹控制器 + */ + const cleanup = () => { + if (trajectoryPlayTimer.value) { + window.clearInterval(trajectoryPlayTimer.value) + trajectoryPlayTimer.value = null + } + } + + return { + // 状态 + trajectoryPlayState, + + // 方法 + playTrajectory, + pauseTrajectory, + stopTrajectory, + setTrajectorySpeed, + setTrajectoryTime, + setTrajectoryTimeRange, + setupTrajectoryWatcher, + cleanup + } +} diff --git a/web/src/views/HandDevice/Home/components/constants/map.constants.ts b/web/src/views/HandDevice/Home/components/constants/map.constants.ts new file mode 100644 index 0000000..1a835f8 --- /dev/null +++ b/web/src/views/HandDevice/Home/components/constants/map.constants.ts @@ -0,0 +1,69 @@ +/** + * 地图组件常量配置 + */ +import type { StatusDictItem, FenceData } from '../types/map.types' + +// 状态字典配置 +export const STATUS_DICT = { + online: [ + { value: '0', label: '离线', cssClass: '#909399' }, + { value: '1', label: '在线', cssClass: '#67c23a' } + ] as StatusDictItem[], + gas: [ + { value: '0', label: '正常', cssClass: '#67c23a' }, + { value: '1', label: '一级气体报警', cssClass: '#e6a23c' }, + { value: '2', label: '二级气体告警', cssClass: '#f56c6c' } + ] as StatusDictItem[], + battery: [ + { value: '0', label: '正常', cssClass: '#67c23a' }, + { value: '1', label: '一级低电量报警', cssClass: '#e6a23c' }, + { value: '2', label: '二级低电量报警', cssClass: '#f56c6c' } + ] as StatusDictItem[], + fence: [ + { value: '0', label: '正常', cssClass: '#67c23a' }, + { value: '1', label: '一级围栏报警', cssClass: '#e6a23c' }, + { value: '2', label: '二级围栏报警', cssClass: '#f56c6c' } + ] as StatusDictItem[] +} + +// 状态优先级定义 (数字越小优先级越高) +export const STATUS_PRIORITY = { + gas_2: 1, + gas_1: 2, + battery_2: 3, + battery_1: 4, + fence_2: 5, + fence_1: 6, + offline: 7, + normal: 8 +} as const + +// 状态顺序数组 +export const STATUS_ORDER = Object.keys(STATUS_PRIORITY) as Array + +// 默认标记数据 +export const DEFAULT_MARKERS = [] + +// 地图默认配置 +export const MAP_DEFAULTS = { + tileUrl: 'http://qtbj.icpcdev.site/roadmap/{z}/{x}/{y}.png', + center: [116.3912757, 39.906217] as [number, number], + zoom: 10, + maxZoom: 18, + minZoom: 0, + enableCluster: true, + clusterDistance: 0 +} + +// 动画配置 +export const ANIMATION_CONFIG = { + duration: 3, // 动画周期(秒) + rippleCount: 5, // 波纹圈数量 + phaseOffset: 0.6, // 波纹圈错开时间(秒) + targetFPS: 60, // 目标帧率 + clusterThreshold: 12, // 聚合阈值 + minRadius: 6, // 最小半径 + maxRadius: 31, // 最大半径 + minOpacity: 0.05 // 最小透明度阈值 +} +export const DEFAULT_FENCES = [] as FenceData[] diff --git a/web/src/views/HandDevice/Home/components/services/animation.service.ts b/web/src/views/HandDevice/Home/components/services/animation.service.ts new file mode 100644 index 0000000..6adc4b0 --- /dev/null +++ b/web/src/views/HandDevice/Home/components/services/animation.service.ts @@ -0,0 +1,162 @@ +/** + * 动画服务类 + */ +import { Vector as VectorLayer } from 'ol/layer' +import { Vector as VectorSource } from 'ol/source' +import { Feature } from 'ol' +import { Point } from 'ol/geom' +import { Style, Circle, Fill, Stroke } from 'ol/style' +import { fromLonLat } from 'ol/proj' +import type { MarkerData } from '../types/map.types' +import { getHighestPriorityStatus, getStatusColor } from '../utils/map.utils' +import { ANIMATION_CONFIG } from '../constants/map.constants' + +export class AnimationService { + private rippleLayer: VectorLayer | null = null + private animationTimer: number | null = null + private map: any = null + private enableCluster: boolean = true + + /** + * 创建波纹图层 + */ + createRippleLayer( + markers: MarkerData[], + map: any, + enableCluster: boolean = true + ): VectorLayer { + this.map = map + this.enableCluster = enableCluster + const source = new VectorSource() + + // 为每个标记添加波纹效果 + markers.forEach((marker) => { + const feature = new Feature({ + geometry: new Point(fromLonLat(marker.coordinates)), + markerData: marker + }) + + const status = getHighestPriorityStatus(marker) + const color = getStatusColor(status) + + // 设置动画开始时间 + feature.set('animationStart', Date.now()) + feature.set('rippleColor', color) + + source.addFeature(feature) + }) + + this.rippleLayer = new VectorLayer({ + source: source, + style: (feature) => { + // 检查当前缩放级别,如果缩放级别较低(聚合状态),不显示波纹 + const currentZoom = this.map?.getView().getZoom() || 0 + + // 如果启用了聚合且zoom级别较低,不显示波纹 + if (this.enableCluster && currentZoom < ANIMATION_CONFIG.clusterThreshold) { + return [] // 不显示波纹 + } + + const startTime = feature.get('animationStart') + const color = feature.get('rippleColor') + const elapsed = (Date.now() - startTime) / 1000 // 秒 + + // 创建多个波纹圈 + const styles: Style[] = [] + + for (let i = 0; i < ANIMATION_CONFIG.rippleCount; i++) { + const phase = (elapsed + i * ANIMATION_CONFIG.phaseOffset) % ANIMATION_CONFIG.duration + const progress = phase / ANIMATION_CONFIG.duration // 0-1的进度 + + // 使用缓动函数使动画更平滑 + const easeProgress = 1 - Math.pow(1 - progress, 3) // ease-out cubic + + // 计算半径和透明度 + const radius = + ANIMATION_CONFIG.minRadius + + easeProgress * (ANIMATION_CONFIG.maxRadius - ANIMATION_CONFIG.minRadius) + const opacity = Math.max(0, 1 - easeProgress) // 1到0的透明度 + + if (opacity > ANIMATION_CONFIG.minOpacity) { + // 计算颜色透明度 + const alpha = Math.floor(opacity * 255) + .toString(16) + .padStart(2, '0') + const strokeColor = color + alpha + + styles.push( + new Style({ + image: new Circle({ + radius: radius, + fill: new Fill({ + color: 'transparent' + }), + stroke: new Stroke({ + color: strokeColor, + width: Math.max(1, 3 - i * 0.4) // 动态调整线宽 + }) + }) + }) + ) + } + } + + return styles + } + }) + + return this.rippleLayer + } + + /** + * 启动波纹动画 + */ + startAnimation(): void { + let lastUpdateTime = 0 + const frameInterval = 1000 / ANIMATION_CONFIG.targetFPS // 帧间隔 + + const animateRipples = (currentTime: number) => { + if (this.rippleLayer && currentTime - lastUpdateTime >= frameInterval) { + this.rippleLayer.getSource()?.changed() + lastUpdateTime = currentTime + } + this.animationTimer = requestAnimationFrame(animateRipples) + } + animateRipples(0) + } + + /** + * 停止波纹动画 + */ + stopAnimation(): void { + if (this.animationTimer) { + cancelAnimationFrame(this.animationTimer) + this.animationTimer = null + } + } + + /** + * 更新波纹图层 + */ + updateRipples(): void { + if (this.rippleLayer) { + this.rippleLayer.getSource()?.changed() + } + } + + /** + * 获取波纹图层 + */ + getRippleLayer(): VectorLayer | null { + return this.rippleLayer + } + + /** + * 销毁动画服务 + */ + destroy(): void { + this.stopAnimation() + this.rippleLayer = null + this.map = null + } +} diff --git a/web/src/views/HandDevice/Home/components/services/fence-draw.service.ts b/web/src/views/HandDevice/Home/components/services/fence-draw.service.ts new file mode 100644 index 0000000..95f95d7 --- /dev/null +++ b/web/src/views/HandDevice/Home/components/services/fence-draw.service.ts @@ -0,0 +1,264 @@ +/** + * 围栏绘制服务类 + */ +import { Vector as VectorLayer } from 'ol/layer' +import { Vector as VectorSource } from 'ol/source' +import { Draw, Modify, Snap } from 'ol/interaction' +import { Style, Stroke, Fill, Circle } from 'ol/style' +import { Polygon } from 'ol/geom' +import { Feature } from 'ol' +import { toLonLat, fromLonLat } from 'ol/proj' +import type { FenceData } from '../types/map.types' + +export class FenceDrawService { + private map: any = null + private drawLayer: VectorLayer | null = null + private drawInteraction: Draw | null = null + private modifyInteraction: Modify | null = null + private snapInteraction: Snap | null = null + private isDrawing: boolean = false + + // 绘制完成回调 + private onDrawComplete: ((coordinates: [number, number][]) => void) | null = null + + /** + * 初始化绘制服务 + */ + init(map: any): void { + this.map = map + this.createDrawLayer() + } + + /** + * 创建绘制图层 + */ + private createDrawLayer(): void { + const source = new VectorSource() + + this.drawLayer = new VectorLayer({ + source: source, + style: new Style({ + stroke: new Stroke({ + color: '#409EFF', + width: 3, + lineDash: [5, 5] + }), + fill: new Fill({ + color: 'rgba(64, 158, 255, 0.1)' + }), + image: new Circle({ + radius: 6, + fill: new Fill({ + color: '#409EFF' + }), + stroke: new Stroke({ + color: '#fff', + width: 2 + }) + }) + }), + zIndex: 10 // 确保绘制图层在最上层 + }) + + this.map.addLayer(this.drawLayer) + } + + /** + * 开始绘制围栏 + */ + startDrawing(onComplete: (coordinates: [number, number][]) => void): void { + if (this.isDrawing) { + this.stopDrawing() + } + + this.onDrawComplete = onComplete + this.isDrawing = true + + // 创建绘制交互 + this.drawInteraction = new Draw({ + source: this.drawLayer!.getSource()!, + type: 'Polygon', + style: new Style({ + stroke: new Stroke({ + color: '#409EFF', + width: 2, + lineDash: [5, 5] + }), + fill: new Fill({ + color: 'rgba(64, 158, 255, 0.1)' + }), + image: new Circle({ + radius: 6, + fill: new Fill({ + color: '#409EFF' + }), + stroke: new Stroke({ + color: '#fff', + width: 2 + }) + }) + }) + }) + + // 监听绘制完成事件 + this.drawInteraction.on('drawend', (event) => { + const feature = event.feature + const geometry = feature.getGeometry() as Polygon + const coordinates = geometry.getCoordinates()[0] + + // 转换为经纬度坐标 + const lonLatCoordinates = coordinates.map((coord) => toLonLat(coord)) as [number, number][] + + // 移除最后一个重复的点 + if (lonLatCoordinates.length > 1) { + lonLatCoordinates.pop() + } + + // 调用完成回调 + if (this.onDrawComplete) { + this.onDrawComplete(lonLatCoordinates) + } + + // 立即清除绘制的特征,避免在正式围栏图层中重复显示 + setTimeout(() => { + if (this.drawLayer) { + const source = this.drawLayer.getSource() + if (source) { + source.clear() + } + } + }, 100) + + // 停止绘制 + this.stopDrawing() + }) + + // 创建修改交互 + this.modifyInteraction = new Modify({ + source: this.drawLayer!.getSource()! + }) + + // 创建捕捉交互 + this.snapInteraction = new Snap({ + source: this.drawLayer!.getSource()! + }) + + // 添加交互到地图 + this.map.addInteraction(this.drawInteraction) + this.map.addInteraction(this.modifyInteraction) + this.map.addInteraction(this.snapInteraction) + + // 改变鼠标样式 + this.map.getViewport().style.cursor = 'crosshair' + } + + /** + * 停止绘制 + */ + stopDrawing(): void { + if (!this.isDrawing) return + + this.isDrawing = false + + // 移除交互 + if (this.drawInteraction) { + this.map.removeInteraction(this.drawInteraction) + this.drawInteraction = null + } + if (this.modifyInteraction) { + this.map.removeInteraction(this.modifyInteraction) + this.modifyInteraction = null + } + if (this.snapInteraction) { + this.map.removeInteraction(this.snapInteraction) + this.snapInteraction = null + } + + // 恢复鼠标样式 + this.map.getViewport().style.cursor = '' + + // 清空绘制图层 + if (this.drawLayer) { + const source = this.drawLayer.getSource() + if (source) { + source.clear() + } + } + + this.onDrawComplete = null + } + + /** + * 取消绘制 + */ + cancelDrawing(): void { + this.stopDrawing() + } + + /** + * 检查是否正在绘制 + */ + isCurrentlyDrawing(): boolean { + return this.isDrawing + } + + /** + * 清除绘制图层 + */ + clearDrawLayer(): void { + if (this.drawLayer) { + const source = this.drawLayer.getSource() + if (source) { + source.clear() + } + } + } + + /** + * 显示编辑围栏 + */ + showEditFence(fence: FenceData): void { + this.clearDrawLayer() + + if (this.drawLayer && fence.fenceRange.length > 0) { + // 将围栏坐标转换为地图坐标并创建多边形 + const coordinates = fence.fenceRange.map((coord) => [coord[0], coord[1]]) + + // 确保多边形闭合 + if (coordinates.length > 0) { + const lastCoord = coordinates[coordinates.length - 1] + const firstCoord = coordinates[0] + if (lastCoord[0] !== firstCoord[0] || lastCoord[1] !== firstCoord[1]) { + coordinates.push(firstCoord) + } + } + + const polygon = new Polygon([coordinates]) + polygon.transform('EPSG:4326', 'EPSG:3857') + + const feature = this.drawLayer.getSource()?.getFeatures()[0] + if (feature) { + feature.setGeometry(polygon) + } else { + const newFeature = new Feature({ + geometry: polygon + }) + this.drawLayer.getSource()?.addFeature(newFeature) + } + } + } + + /** + * 销毁服务 + */ + destroy(): void { + this.stopDrawing() + + if (this.drawLayer && this.map) { + this.map.removeLayer(this.drawLayer) + this.drawLayer = null + } + + this.map = null + } +} diff --git a/web/src/views/HandDevice/Home/components/services/fence.service.ts b/web/src/views/HandDevice/Home/components/services/fence.service.ts new file mode 100644 index 0000000..54f3f61 --- /dev/null +++ b/web/src/views/HandDevice/Home/components/services/fence.service.ts @@ -0,0 +1,328 @@ +/** + * 围栏服务类 + */ +import { Vector as VectorLayer } from 'ol/layer' +import { Vector as VectorSource } from 'ol/source' +import { Feature } from 'ol' +import { Polygon, Point } from 'ol/geom' +import { Style, Stroke, Fill, Circle, Text } from 'ol/style' +import { fromLonLat } from 'ol/proj' +import type { FenceData, MarkerData } from '../types/map.types' + +export class FenceService { + private fenceLayer: VectorLayer | null = null + private fenceData: FenceData[] = [] + private map: any = null + private isVisible: boolean = true + + /** + * 创建围栏图层 + */ + createFenceLayer(fences: FenceData[], map: any): VectorLayer { + this.map = map + this.fenceData = fences + const source = new VectorSource() + + fences.forEach((fence) => { + // 创建围栏多边形特征 + const coordinates = fence.fenceRange.map((coord) => fromLonLat(coord)) + + // 确保围栏是闭合的 + if (coordinates.length > 0) { + const lastCoord = coordinates[coordinates.length - 1] + const firstCoord = coordinates[0] + if (lastCoord[0] !== firstCoord[0] || lastCoord[1] !== firstCoord[1]) { + coordinates.push(firstCoord) + } + } + + const feature = new Feature({ + geometry: new Polygon([coordinates]), + fenceData: fence + }) + + // 设置围栏样式 + feature.setStyle(this.createFenceStyle(fence)) + feature.set('type', 'fence') + feature.set('fenceId', fence.id) + source.addFeature(feature) + + // 创建围栏中心点标签 + const centerFeature = this.createFenceCenterLabel(fence, coordinates) + if (centerFeature) { + source.addFeature(centerFeature) + } + }) + + this.fenceLayer = new VectorLayer({ + source: source, + zIndex: 1 // 确保围栏在标记点下方 + }) + + return this.fenceLayer + } + + /** + * 创建围栏样式 + */ + private createFenceStyle(fence: FenceData): Style { + let strokeColor = '#1890ff' + let fillColor = 'rgba(24, 144, 255, 0.1)' + let strokeWidth = 2 + + // 根据围栏状态设置样式 + switch (fence.status) { + case 0: + strokeColor = '#67c23a' + fillColor = 'rgba(103, 194, 58, 0.1)' + break + case 1: + strokeColor = '#e6a23c' + fillColor = 'rgba(230, 162, 60, 0.15)' + strokeWidth = 3 + break + case 2: + strokeColor = '#f56c6c' + fillColor = 'rgba(245, 108, 108, 0.2)' + strokeWidth = 4 + break + } + + // 根据围栏类型调整样式 + const lineDash = fence.type === 1 ? [10, 5] : undefined + + return new Style({ + stroke: new Stroke({ + color: strokeColor, + width: strokeWidth, + lineDash: lineDash + }), + fill: new Fill({ + color: fillColor + }) + }) + } + + /** + * 创建围栏中心点标签 + */ + private createFenceCenterLabel(fence: FenceData, coordinates: number[][]): Feature | null { + if (coordinates.length === 0) return null + + // 计算围栏中心点 + const centerX = coordinates.reduce((sum, coord) => sum + coord[0], 0) / coordinates.length + const centerY = coordinates.reduce((sum, coord) => sum + coord[1], 0) / coordinates.length + + const labelFeature = new Feature({ + geometry: new Point([centerX, centerY]), + fenceData: fence + }) + + // 设置标签样式 + labelFeature.setStyle( + new Style({ + text: new Text({ + text: fence.name, + font: '12px Arial', + fill: new Fill({ + color: '#333' + }), + stroke: new Stroke({ + color: '#fff', + width: 2 + }), + backgroundFill: new Fill({ + color: 'rgba(255, 255, 255, 0.8)' + }), + backgroundStroke: new Stroke({ + color: '#ccc', + width: 1 + }), + padding: [2, 4, 2, 4] + }) + }) + ) + + labelFeature.set('type', 'fence-label') + labelFeature.set('fenceId', fence.id) + + return labelFeature + } + + /** + * 获取围栏图层 + */ + getFenceLayer(): VectorLayer | null { + return this.fenceLayer + } + + /** + * 显示围栏 + */ + showFences(): void { + if (this.fenceLayer) { + this.fenceLayer.setVisible(true) + this.isVisible = true + } + } + + /** + * 隐藏围栏 + */ + hideFences(): void { + if (this.fenceLayer) { + this.fenceLayer.setVisible(false) + this.isVisible = false + } + } + + /** + * 切换围栏显示状态 + */ + toggleFences(): boolean { + if (this.isVisible) { + this.hideFences() + } else { + this.showFences() + } + return this.isVisible + } + + /** + * 设置围栏数据 + */ + setFenceData(fences: FenceData[]): void { + this.fenceData = fences + if (this.fenceLayer && this.map) { + // 重新创建围栏图层 + const source = this.fenceLayer.getSource() + if (source) { + source.clear() + + fences.forEach((fence) => { + const coordinates = fence.fenceRange.map((coord) => fromLonLat(coord)) + + if (coordinates.length > 0) { + const lastCoord = coordinates[coordinates.length - 1] + const firstCoord = coordinates[0] + if (lastCoord[0] !== firstCoord[0] || lastCoord[1] !== firstCoord[1]) { + coordinates.push(firstCoord) + } + } + + const feature = new Feature({ + geometry: new Polygon([coordinates]), + fenceData: fence + }) + + feature.setStyle(this.createFenceStyle(fence)) + feature.set('type', 'fence') + feature.set('fenceId', fence.id) + source.addFeature(feature) + + const centerFeature = this.createFenceCenterLabel(fence, coordinates) + if (centerFeature) { + source.addFeature(centerFeature) + } + }) + } + } + } + + /** + * 根据ID获取围栏数据 + */ + getFenceById(id: string): FenceData | undefined { + return this.fenceData.find((fence) => fence.id === id) + } + + /** + * 检查点是否在围栏内 + */ + isPointInFence(point: [number, number], fenceId?: string): boolean { + const fences = fenceId ? this.fenceData.filter((fence) => fence.id === fenceId) : this.fenceData + + for (const fence of fences) { + if (this.pointInPolygon(point, fence.fenceRange)) { + return true + } + } + return false + } + + /** + * 点在多边形内判断算法(射线法) + */ + private pointInPolygon(point: [number, number], polygon: [number, number][]): boolean { + const [x, y] = point + let inside = false + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [xi, yi] = polygon[i] + const [xj, yj] = polygon[j] + + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) { + inside = !inside + } + } + + return inside + } + + /** + * 获取标记点的围栏状态 + */ + getMarkerFenceStatus(marker: MarkerData): { isInFence: boolean; fenceIds: string[] } { + const fenceIds: string[] = [] + + for (const fence of this.fenceData) { + if (this.pointInPolygon(marker.coordinates, fence.fenceRange)) { + fenceIds.push(fence.id) + } + } + + return { + isInFence: fenceIds.length > 0, + fenceIds + } + } + + /** + * 更新围栏状态 + */ + updateFenceStatus(fenceId: string, status: number): void { + const fence = this.fenceData.find((f) => f.id === fenceId) + if (fence) { + fence.status = status + + // 更新图层中对应的特征样式 + if (this.fenceLayer) { + const source = this.fenceLayer.getSource() + if (source) { + const features = source.getFeatures() + const fenceFeature = features.find( + (feature) => feature.get('type') === 'fence' && feature.get('fenceId') === fenceId + ) + if (fenceFeature) { + fenceFeature.setStyle(this.createFenceStyle(fence)) + } + } + } + } + } + + /** + * 销毁服务 + */ + destroy(): void { + if (this.fenceLayer) { + const source = this.fenceLayer.getSource() + if (source) { + source.clear() + } + } + this.fenceLayer = null + this.fenceData = [] + this.map = null + } +} diff --git a/web/src/views/HandDevice/Home/components/services/map.service.ts b/web/src/views/HandDevice/Home/components/services/map.service.ts new file mode 100644 index 0000000..048711f --- /dev/null +++ b/web/src/views/HandDevice/Home/components/services/map.service.ts @@ -0,0 +1,114 @@ +/** + * 地图服务类 + */ +import { Map, View } from 'ol' +import { Tile as TileLayer } from 'ol/layer' +import { OSM, XYZ } from 'ol/source' +import { fromLonLat } from 'ol/proj' +import Overlay from 'ol/Overlay' +import type { MapProps, MapInstance } from '../types/map.types' + +export class MapService { + private map: Map | null = null + private tileLayer: TileLayer | null = null + private popupOverlay: Overlay | null = null + + /** + * 创建瓦片图层 + */ + private createTileLayer(props: MapProps): TileLayer { + const source = new XYZ({ + url: props.tileUrl!, + maxZoom: props.maxZoom, + minZoom: props.minZoom + }) + return new TileLayer({ + source: source + }) + } + + /** + * 创建弹窗 + */ + private createPopup(): Overlay { + const popupElement = document.createElement('div') + popupElement.className = 'marker-popup' + popupElement.style.cssText = ` + background: white; + border: 1px solid #ccc; + border-radius: 6px; + padding: 12px 16px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + font-size: 12px; + max-width: 280px; + min-width: 200px; + pointer-events: none; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + ` + + this.popupOverlay = new Overlay({ + element: popupElement, + positioning: 'bottom-center', + stopEvent: false, + offset: [0, -10] + }) + + return this.popupOverlay + } + + /** + * 初始化地图 + */ + initMap(container: HTMLElement, props: MapProps): MapInstance { + this.tileLayer = this.createTileLayer(props) + const popup = this.createPopup() + + const center = fromLonLat(props.center!) + + this.map = new Map({ + target: container, + layers: [this.tileLayer], + overlays: [popup], + view: new View({ + center: center, + zoom: props.zoom, + maxZoom: props.maxZoom, + minZoom: props.minZoom + }) + }) + + return { + map: this.map, + tileLayer: this.tileLayer, + markerLayer: null, + rippleLayer: null, + popupOverlay: this.popupOverlay + } + } + + /** + * 获取地图实例 + */ + getMap(): Map | null { + return this.map + } + + /** + * 获取弹窗实例 + */ + getPopupOverlay(): Overlay | null { + return this.popupOverlay + } + + /** + * 销毁地图 + */ + destroy(): void { + if (this.map) { + this.map.setTarget(undefined) + this.map = null + this.tileLayer = null + this.popupOverlay = null + } + } +} diff --git a/web/src/views/HandDevice/Home/components/services/marker.service.ts b/web/src/views/HandDevice/Home/components/services/marker.service.ts new file mode 100644 index 0000000..ed2599c --- /dev/null +++ b/web/src/views/HandDevice/Home/components/services/marker.service.ts @@ -0,0 +1,196 @@ +/** + * 标记服务类 + */ +import { Vector as VectorLayer } from 'ol/layer' +import { Vector as VectorSource, Cluster } from 'ol/source' +import { Feature } from 'ol' +import { Point } from 'ol/geom' +import { Style } from 'ol/style' +import { fromLonLat } from 'ol/proj' +import type { MarkerData, MapProps } from '../types/map.types' +import { createMarkerStyle, getClusterMarkerData } from '../utils/map.utils' + +export class MarkerService { + private markerLayer: VectorLayer | null = null + private currentProps: MapProps | null = null + private map: any = null + + /** + * 设置地图实例 + */ + setMap(map: any): void { + this.map = map + } + + /** + * 创建标记图层 + */ + createMarkerLayer(props: MapProps, map?: any): VectorLayer { + // 保存地图实例 + if (map) { + this.map = map + } + + // 保存当前props + this.currentProps = { ...props } + + const source = new VectorSource() + // 添加标记 + const markers = props.markers || [] + markers.forEach((marker) => { + const feature = new Feature({ + geometry: new Point(fromLonLat(marker.coordinates)), + markerData: marker + }) + feature.setStyle(createMarkerStyle(marker)) + source.addFeature(feature) + }) + + // 检查是否应该强制使用单个marker模式 + const shouldForceSingleMark = () => { + if (!props.forceSingleMark || !this.map) return false + const currentZoom = this.map.getView().getZoom() + return currentZoom >= props.forceSingleMark + } + + // 如果启用聚合且不强制使用单个marker模式 + if (props.enableCluster && !shouldForceSingleMark()) { + const clusterSource = new Cluster({ + source: source, + distance: Math.max(props.clusterDistance || 40, 10) // 确保最小距离为10像素 + }) + + this.markerLayer = new VectorLayer({ + source: clusterSource, + style: (feature) => { + const features = feature.get('features') + + // 确保features存在且不为空 + if (!features || features.length === 0) { + return new Style() // 返回空样式,隐藏无效的feature + } + + if (features.length === 1) { + // 单个marker + const markerData = features[0].get('markerData') + return markerData ? createMarkerStyle(markerData) : new Style() + } else { + // 聚合marker + const highestStatus = getClusterMarkerData(features) + return createMarkerStyle(highestStatus, true, features.length) + } + } + }) + } else { + this.markerLayer = new VectorLayer({ + source: source + }) + } + + return this.markerLayer + } + + /** + * 获取标记图层 + */ + getMarkerLayer(): VectorLayer | null { + return this.markerLayer + } + + /** + * 更新标记数据 + */ + updateMarkers(markers: MarkerData[]): void { + if (!this.currentProps) return + + // 更新props中的markers + this.currentProps.markers = markers + + // 完全重新创建markerLayer + const newLayer = this.createMarkerLayerFromProps(this.currentProps) + + // 如果有旧的layer,将其从地图中移除 + if (this.markerLayer) { + // 这里需要外部调用来移除旧layer并添加新layer + // 我们只负责创建新的layer + } + + this.markerLayer = newLayer + } + + /** + * 从props创建markerLayer(内部方法) + */ + private createMarkerLayerFromProps(props: MapProps): VectorLayer { + const source = new VectorSource() + // 添加标记 + const markers = props.markers || [] + markers.forEach((marker) => { + const feature = new Feature({ + geometry: new Point(fromLonLat(marker.coordinates)), + markerData: marker + }) + feature.setStyle(createMarkerStyle(marker)) + source.addFeature(feature) + }) + + // 检查是否应该强制使用单个marker模式 + const shouldForceSingleMark = () => { + if (!props.forceSingleMark || !this.map) return false + const currentZoom = this.map.getView().getZoom() + return currentZoom >= props.forceSingleMark + } + + // 如果启用聚合且不强制使用单个marker模式 + if (props.enableCluster && !shouldForceSingleMark()) { + const clusterSource = new Cluster({ + source: source, + distance: Math.max(props.clusterDistance || 40, 10) + }) + + return new VectorLayer({ + source: clusterSource, + style: (feature) => { + const features = feature.get('features') + + // 确保features存在且不为空 + if (!features || features.length === 0) { + return new Style() // 返回空样式,隐藏无效的feature + } + + if (features.length === 1) { + // 单个marker + const markerData = features[0].get('markerData') + return markerData ? createMarkerStyle(markerData) : new Style() + } else { + // 聚合marker + const highestStatus = getClusterMarkerData(features) + return createMarkerStyle(highestStatus, true, features.length) + } + } + }) + } else { + return new VectorLayer({ + source: source + }) + } + } + + /** + * 刷新标记样式(用于聚合更新) + */ + refreshStyles(): void { + if (this.markerLayer) { + this.markerLayer.changed() + } + } + + /** + * 销毁标记图层 + */ + destroy(): void { + this.markerLayer = null + this.currentProps = null + this.map = null + } +} diff --git a/web/src/views/HandDevice/Home/components/services/popup.service.ts b/web/src/views/HandDevice/Home/components/services/popup.service.ts new file mode 100644 index 0000000..07b04aa --- /dev/null +++ b/web/src/views/HandDevice/Home/components/services/popup.service.ts @@ -0,0 +1,86 @@ +/** + * 弹窗服务类 + */ +import type { MarkerData, DetectorInfo } from '../types/map.types' +import { + getHighestPriorityStatus, + getStatusLabel, + getStatusColor, + createClusterPopupHTML, + sortDetectorsByPriority +} from '../utils/map.utils' + +export class PopupService { + /** + * 处理聚合标记弹窗 + */ + handleClusterPopup(features: any[]): string { + // 收集所有探测器信息 + const detectorList: DetectorInfo[] = features.map((f) => { + const markerData = f.get('markerData') as MarkerData + const status = getHighestPriorityStatus(markerData) + return { + name: markerData.name, + status, + statusLabel: getStatusLabel(status), + statusColor: getStatusColor(status) + } + }) + + // 按优先级排序 + const sortedDetectorList = sortDetectorsByPriority(detectorList) + + // 生成弹窗HTML + return createClusterPopupHTML(sortedDetectorList) + } + + /** + * 处理单个标记弹窗 + */ + handleSingleMarkerPopup(markerData: MarkerData): string { + const status = getHighestPriorityStatus(markerData) + return ` +
${markerData.name}
+
+ 状态: ${getStatusLabel(status)} +
+
+ 坐标: ${markerData.coordinates[0].toFixed(6)}, ${markerData.coordinates[1].toFixed(6)} +
+ ` + } + + /** + * 处理默认弹窗 + */ + handleDefaultPopup(): string { + return ` +
标记
+
未知标记
+ ` + } + + /** + * 根据特征类型处理弹窗内容 + */ + handlePopupContent(feature: any): string { + const markerData = feature.get('markerData') + const features = feature.get('features') + + // 处理聚合标记 + if (features && features.length > 1) { + return this.handleClusterPopup(features) + } else if (features && features.length === 1) { + // 处理聚合中的单个标记 + const singleMarkerData = features[0].get('markerData') as MarkerData + if (singleMarkerData) { + return this.handleSingleMarkerPopup(singleMarkerData) + } + } else if (markerData) { + // 处理非聚合的单个标记 + return this.handleSingleMarkerPopup(markerData as MarkerData) + } + + return this.handleDefaultPopup() + } +} diff --git a/web/src/views/HandDevice/Home/components/services/trajectory.service.ts b/web/src/views/HandDevice/Home/components/services/trajectory.service.ts new file mode 100644 index 0000000..9f2bd2f --- /dev/null +++ b/web/src/views/HandDevice/Home/components/services/trajectory.service.ts @@ -0,0 +1,502 @@ +/** + * 轨迹服务类 + */ +import { Vector as VectorLayer } from 'ol/layer' +import { Vector as VectorSource } from 'ol/source' +import { Feature } from 'ol' +import { LineString, Point } from 'ol/geom' +import { Style, Stroke, Circle, Fill, Text, Icon } from 'ol/style' +import { fromLonLat } from 'ol/proj' +import type { + TrajectoryData, + TrajectoryPoint, + TrajectoryPlayState, + MarkerData +} from '../types/map.types' +import { createLocationIconSVG, getHighestPriorityStatus, getStatusColor } from '../utils/map.utils' +import dayjs from 'dayjs' + +export class TrajectoryService { + private trajectoryLayer: VectorLayer | null = null + private trajectoryData: TrajectoryData[] = [] + private map: any = null + private animationTimer: number | null = null + + // 当前移动的 marker 图层 + private movingMarkerLayer: VectorLayer | null = null + + // 按时间排序的所有轨迹点 + private sortedTrajectoryPoints: Array = [] + + /** + * 创建轨迹图层 + */ + createTrajectoryLayer(map: any): VectorLayer { + this.map = map + const source = new VectorSource() + + this.trajectoryLayer = new VectorLayer({ + source: source, + style: (feature) => { + const featureType = feature.get('type') + + if (featureType === 'trajectory') { + // 轨迹线条样式 + return new Style({ + stroke: new Stroke({ + color: feature.get('color') || '#1890ff', + width: feature.get('width') || 3, + lineDash: [5, 5] // 虚线效果 + }) + }) + } else if (featureType === 'trajectory-point') { + // 轨迹点样式 + const isActive = feature.get('isActive') + const pointRadius = feature.get('pointRadius') || 4 + const activePointRadius = feature.get('activePointRadius') || 8 + const showTimeLabel = feature.get('showTimeLabel') !== false // 默认显示时间标签 + + // 根据轨迹点的data状态获取颜色 + const pointData = feature.get('pointData') + let color = feature.get('color') || '#1890ff' // 默认颜色 + + if (pointData) { + // 将点数据构造为MarkerData格式以使用现有的状态计算函数 + const markerData = { + id: -1, + coordinates: [0, 0] as [number, number], + name: '', + gasStatus: pointData.gasStatus || '0', + batteryStatus: pointData.batteryStatus || '0', + fenceStatus: pointData.fenceStatus || '0', + onlineStatus: pointData.onlineStatus || '1' + } + const status = getHighestPriorityStatus(markerData) + color = getStatusColor(status) + } + + return new Style({ + image: new Circle({ + radius: isActive ? activePointRadius : pointRadius, + fill: new Fill({ + color: isActive ? color : '#ffffff' + }), + stroke: new Stroke({ + color: color, + width: isActive ? 3 : 2 + }) + }), + text: + isActive && showTimeLabel + ? new Text({ + text: feature.get('timeText') || '', + font: '12px Arial', + fill: new Fill({ + color: '#333' + }), + offsetY: -15, + backgroundFill: new Fill({ + color: 'rgba(255, 255, 255, 0.8)' + }), + padding: [2, 4, 2, 4] + }) + : undefined + }) + } + + return new Style() + } + }) + + // 创建移动中的 marker 图层 + this.movingMarkerLayer = new VectorLayer({ + source: new VectorSource(), + style: (feature) => { + const color = feature.get('color') || '#ff4757' + return new Style({ + image: new Icon({ + src: createLocationIconSVG(color, 32), // 使用位置图标,大小为32px + scale: 1, + anchor: [0.5, 1], // 锚点设置在底部中心 + anchorXUnits: 'fraction', + anchorYUnits: 'fraction' + }), + text: new Text({ + text: feature.get('deviceName') || '', + font: 'bold 12px Arial', + fill: new Fill({ + color: '#ffffff' + }), + offsetY: -40, // 调整文字位置,避免与图标重叠 + backgroundFill: new Fill({ + color: 'rgba(0, 0, 0, 0.7)' + }), + padding: [2, 6, 2, 6] + }) + }) + } + }) + + map.addLayer(this.movingMarkerLayer) + return this.trajectoryLayer + } + + /** + * 设置轨迹数据并从 markers 中提取轨迹 + */ + setTrajectoryData(markers: MarkerData[]): void { + // 从 markers 中提取轨迹数据 + this.trajectoryData = markers + .filter((marker) => marker.data && marker.data.length > 0) + .map((marker) => ({ + deviceId: marker.id, + name: marker.name, + points: marker.data.map((item: any) => ({ + coordinates: [item.lng, item.lat] as [number, number], + timestamp: dayjs(item.time, 'YYYY-MM-DD HH:mm:ss').valueOf(), + data: item + })), + color: '#1890ff', + width: 3, + pointRadius: 2, // 默认轨迹点半径 + activePointRadius: 2, // 默认激活状态轨迹点半径 + showTimeLabel: false // 默认显示时间标签 + })) + + // 创建按时间排序的所有轨迹点 + this.sortedTrajectoryPoints = [] + this.trajectoryData.forEach((trajectory) => { + trajectory.points.forEach((point, index) => { + this.sortedTrajectoryPoints.push({ + ...point, + deviceId: trajectory.deviceId, + pointIndex: index + }) + }) + }) + + // 按时间戳排序 + this.sortedTrajectoryPoints.sort((a, b) => a.timestamp - b.timestamp) + + this.renderTrajectories() + } + + /** + * 根据时间范围过滤轨迹数据 + */ + filterTrajectoryByTimeRange(startTime: number, endTime: number): void { + this.sortedTrajectoryPoints = this.sortedTrajectoryPoints.filter( + (point) => point.timestamp >= startTime && point.timestamp <= endTime + ) + this.renderTrajectories() + } + + /** + * 根据当前播放时间更新显示 + */ + updateByPlayState(playState: TrajectoryPlayState): void { + if (this.sortedTrajectoryPoints.length === 0) return + + // 为每个设备找到当前时间对应的轨迹点 + const currentPoints = this.findPointsForAllDevicesByTime(playState.currentTime) + + if (currentPoints.length > 0) { + // 找到对应点时,更新所有设备的移动marker和轨迹进度 + this.updateAllMovingMarkers(currentPoints) + this.updateTrajectoryProgress(playState.currentTime) + } else { + // 没有找到对应点时(可能时间在轨迹数据开始之前),清除移动marker并重置所有轨迹点状态 + this.clearMovingMarker() + this.updateTrajectoryProgress(playState.currentTime) + } + } + + /** + * 为所有设备查找指定时间戳对应的轨迹点 + */ + private findPointsForAllDevicesByTime(timestamp: number): Array { + const result: Array = [] + + // 为每个设备找到对应时间点的最近轨迹点 + this.trajectoryData.forEach((trajectory) => { + // 找到该设备在指定时间的最近轨迹点 + let closestPoint: TrajectoryPoint | null = null + let minDiff = Infinity + + trajectory.points.forEach((point) => { + if (point.timestamp <= timestamp) { + const diff = Math.abs(point.timestamp - timestamp) + if (diff < minDiff) { + minDiff = diff + closestPoint = { + ...point, + deviceId: trajectory.deviceId + } + } + } + }) + + if (closestPoint) { + result.push(closestPoint) + } + }) + + return result + } + + /** + * 更新所有设备的移动中的 marker + */ + private updateAllMovingMarkers(points: Array): void { + if (!this.movingMarkerLayer) return + + const source = this.movingMarkerLayer.getSource() + source?.clear() + + // 为每个设备创建移动marker + points.forEach((point) => { + // 找到对应设备的信息 + const trajectory = this.trajectoryData.find((t) => t.deviceId === point.deviceId) + if (!trajectory) return + + // 根据轨迹点的data状态获取颜色 + let color = trajectory.color || '#ff4757' // 默认颜色 + + if (point.data) { + // 将点数据构造为MarkerData格式以使用现有的状态计算函数 + const markerData = { + id: -1, + coordinates: [0, 0] as [number, number], + name: '', + gasStatus: point.data.gasStatus || '0', + batteryStatus: point.data.batteryStatus || '0', + fenceStatus: point.data.fenceStatus || '0', + onlineStatus: point.data.onlineStatus || '1' + } + const status = getHighestPriorityStatus(markerData) + color = getStatusColor(status) + } + + const markerFeature = new Feature({ + geometry: new Point(fromLonLat(point.coordinates)), + type: 'moving-marker', + deviceId: point.deviceId, + deviceName: trajectory.name, + color: color, + timestamp: point.timestamp + }) + + source?.addFeature(markerFeature) + }) + } + + /** + * 清除移动中的 marker + */ + private clearMovingMarker(): void { + if (!this.movingMarkerLayer) return + + const source = this.movingMarkerLayer.getSource() + source?.clear() + } + + /** + * 更新轨迹进度显示 + */ + private updateTrajectoryProgress(currentTime: number): void { + if (!this.trajectoryLayer) return + + const source = this.trajectoryLayer.getSource() + if (!source) return + + // 获取所有轨迹点 features + const features = source.getFeatures() + + // 强制更新所有轨迹点的激活状态 + features.forEach((feature) => { + if (feature.get('type') === 'trajectory-point') { + const pointTime = feature.get('timestamp') + const wasActive = feature.get('isActive') + const isActive = pointTime <= currentTime + + // 只有状态真正变化时才设置,以触发重新渲染 + if (wasActive !== isActive) { + feature.set('isActive', isActive) + } + } + }) + + // 强制触发重新渲染 + source.changed() + + // 额外确保样式更新 + this.trajectoryLayer.changed() + } + + /** + * 渲染轨迹 + */ + private renderTrajectories(): void { + if (!this.trajectoryLayer) return + + const source = this.trajectoryLayer.getSource() + source?.clear() + + this.trajectoryData.forEach((trajectory) => { + if (trajectory.points.length < 2) return + + // 创建轨迹线条 + const coordinates = trajectory.points.map((point) => fromLonLat(point.coordinates)) + const lineFeature = new Feature({ + geometry: new LineString(coordinates), + type: 'trajectory', + color: trajectory.color || '#1890ff', + width: trajectory.width || 3, + deviceId: trajectory.deviceId + }) + source?.addFeature(lineFeature) + + // 创建轨迹点 + trajectory.points.forEach((point, index) => { + const pointFeature = new Feature({ + geometry: new Point(fromLonLat(point.coordinates)), + type: 'trajectory-point', + color: trajectory.color || '#1890ff', + isActive: false, // 初始状态为非激活 + timeText: this.formatTime(point.timestamp), + pointIndex: index, + trajectoryId: trajectory.deviceId, + timestamp: point.timestamp, + pointRadius: trajectory.pointRadius || 2, // 传递轨迹点半径 + activePointRadius: trajectory.activePointRadius || 2, // 传递激活状态轨迹点半径 + showTimeLabel: trajectory.showTimeLabel !== false, // 传递时间标签显示控制 + pointData: point.data // 添加点数据以便样式函数使用 + }) + source?.addFeature(pointFeature) + }) + }) + } + + /** + * 设置轨迹点大小 + */ + setTrajectoryPointSize(deviceId: number, pointRadius: number, activePointRadius?: number): void { + const trajectory = this.trajectoryData.find((t) => t.deviceId === deviceId) + if (trajectory) { + trajectory.pointRadius = pointRadius + if (activePointRadius !== undefined) { + trajectory.activePointRadius = activePointRadius + } + this.renderTrajectories() + } + } + + /** + * 设置所有轨迹点大小 + */ + setAllTrajectoryPointSize(pointRadius: number, activePointRadius?: number): void { + this.trajectoryData.forEach((trajectory) => { + trajectory.pointRadius = pointRadius + if (activePointRadius !== undefined) { + trajectory.activePointRadius = activePointRadius + } + }) + this.renderTrajectories() + } + + /** + * 设置时间标签显示状态 + */ + setTimeLabelsVisible(deviceId: number, visible: boolean): void { + const trajectory = this.trajectoryData.find((t) => t.deviceId === deviceId) + if (trajectory) { + trajectory.showTimeLabel = visible + this.renderTrajectories() + } + } + + /** + * 设置所有时间标签显示状态 + */ + setAllTimeLabelsVisible(visible: boolean): void { + this.trajectoryData.forEach((trajectory) => { + trajectory.showTimeLabel = visible + }) + this.renderTrajectories() + } + + /** + * 格式化时间 + */ + private formatTime(timestamp: number): string { + return dayjs(timestamp).format('HH:mm:ss') + } + + /** + * 显示轨迹(当 showTrajectoryControls 为 true 时调用) + */ + showTrajectories(): void { + if (this.trajectoryLayer) { + this.trajectoryLayer.setVisible(true) + } + if (this.movingMarkerLayer) { + this.movingMarkerLayer.setVisible(true) + } + } + + /** + * 隐藏轨迹 + */ + hideTrajectories(): void { + if (this.trajectoryLayer) { + this.trajectoryLayer.setVisible(false) + } + if (this.movingMarkerLayer) { + this.movingMarkerLayer.setVisible(false) + } + } + + /** + * 获取轨迹数据 + */ + getTrajectoryData(): TrajectoryData[] { + return [...this.trajectoryData] + } + + /** + * 获取轨迹图层 + */ + getTrajectoryLayer(): VectorLayer | null { + return this.trajectoryLayer + } + + /** + * 获取移动标记图层 + */ + getMovingMarkerLayer(): VectorLayer | null { + return this.movingMarkerLayer + } + + /** + * 获取地图实例(用于扩展功能) + */ + getMap(): any { + return this.map + } + + /** + * 销毁轨迹服务 + */ + destroy(): void { + if (this.animationTimer) { + clearTimeout(this.animationTimer) + this.animationTimer = null + } + + this.trajectoryLayer = null + this.movingMarkerLayer = null + this.map = null // 清理 map 引用 + this.trajectoryData = [] + this.sortedTrajectoryPoints = [] + } +} diff --git a/web/src/views/HandDevice/Home/components/types/map.types.ts b/web/src/views/HandDevice/Home/components/types/map.types.ts new file mode 100644 index 0000000..1e52cfe --- /dev/null +++ b/web/src/views/HandDevice/Home/components/types/map.types.ts @@ -0,0 +1,151 @@ +/** + * 地图组件相关类型定义 + */ +import { HandDetector } from '@/api/gas/handdetector' +// 状态字典配置 +export interface StatusDictItem { + value: string + label: string + cssClass: string +} + +// 标记状态类型 +export type MarkerStatus = string + +// 标记数据接口 +export interface MarkerData extends HandDetector { + /** 坐标 [经度, 纬度] */ + coordinates: [number, number] + /** 气体状态 */ + gasStatus?: MarkerStatus + /** 电池状态 */ + batteryStatus?: MarkerStatus + /** 围栏状态 */ + fenceStatus?: MarkerStatus + /** 在线状态 */ + onlineStatus?: MarkerStatus + /** 标记标题 */ + name: string + /** 自定义数据 */ + data?: any +} + +// 地图组件 Props 接口 +export interface MapProps { + /** 自定义瓦片图地址模板,支持 {x}, {y}, {z} 占位符 */ + tileUrl?: string + /** 地图中心点坐标 [经度, 纬度] */ + center?: [number, number] + /** 初始缩放级别 */ + zoom?: number + /** 最大缩放级别 */ + maxZoom?: number + /** 最小缩放级别 */ + minZoom?: number + /** 标记数据列表 */ + markers?: MarkerData[] + /** 围栏数据列表 */ + fences?: FenceData[] + /** 是否启用聚合功能 */ + enableCluster?: boolean + /** 聚合距离(像素) */ + clusterDistance?: number + /** 强制单个marker显示的zoom级别阈值 */ + forceSingleMark?: number + /** 是否显示轨迹控制面板 */ + showTrajectories?: boolean + /** 是否显示标记点 */ + showMarkers?: boolean + /** 是否显示围栏 */ + showFences?: boolean + /** 是否显示绘制围栏 */ + showDrawFences?: boolean +} + +// 围栏数据接口 +export interface FenceData { + /** 围栏ID */ + id: string + /** 围栏名称 */ + name: string + /** 围栏范围 */ + fenceRange: [number, number][] + /** 围栏状态 */ + status: number + /** 围栏类型 */ + type: number + /** 围栏备注 */ + remark: string + /** 围栏数据 */ + data: any +} + +// 探测器信息接口 +export interface DetectorInfo { + name: string + status: string + statusLabel: string + statusColor: string +} + +// 轨迹点数据接口 +export interface TrajectoryPoint { + /** 设备ID */ + deviceId?: number + /** 轨迹点索引 */ + pointIndex?: number + /** 坐标 [经度, 纬度] */ + coordinates: [number, number] + /** 时间戳 */ + timestamp: number + /** 速度 (km/h) */ + speed?: number + /** 方向 (度) */ + direction?: number + /** 额外数据 */ + data?: any +} + +// 轨迹数据接口 +export interface TrajectoryData { + /** 设备ID */ + deviceId: number + /** 轨迹点列表 */ + points: TrajectoryPoint[] + /** 轨迹颜色 */ + color?: string + /** 轨迹线宽 */ + width?: number + /** 设备名称 */ + name?: string + /** 轨迹点半径 */ + pointRadius?: number + /** 激活状态轨迹点半径 */ + activePointRadius?: number + /** 是否显示时间标签 */ + showTimeLabel?: boolean +} + +// 轨迹播放状态 +export interface TrajectoryPlayState { + /** 是否正在播放 */ + isPlaying: boolean + /** 当前播放时间 */ + currentTime: number + /** 播放速度倍数 */ + speed: number + /** 播放开始时间 */ + startTime?: number + /** 播放结束时间 */ + endTime?: number +} + +// 地图实例接口 +export interface MapInstance { + map: any + tileLayer: any + markerLayer: any + rippleLayer: any + popupOverlay: any + trajectoryLayer?: any +} diff --git a/web/src/views/HandDevice/Home/components/utils/map.utils.ts b/web/src/views/HandDevice/Home/components/utils/map.utils.ts new file mode 100644 index 0000000..21ac881 --- /dev/null +++ b/web/src/views/HandDevice/Home/components/utils/map.utils.ts @@ -0,0 +1,202 @@ +/** + * 地图工具函数 + */ +import { Style, Text, Circle, Fill, Stroke, Icon } from 'ol/style' +import { Feature } from 'ol' +import type { MarkerData, DetectorInfo } from '../types/map.types' +import { STATUS_DICT, STATUS_PRIORITY, STATUS_ORDER } from '../constants/map.constants' + +/** + * 从字典中查找状态信息 + */ +export const findStatusInfo = ( + dict: (typeof STATUS_DICT)[keyof typeof STATUS_DICT], + value: string +) => { + return dict.find((item) => item.value === value) +} + +/** + * 获取状态映射 + */ +export const getStatusMapping = (type: keyof typeof STATUS_DICT, value: string) => { + const info = findStatusInfo(STATUS_DICT[type], value) + return info ? `${type}_${value}` : null +} + +/** + * 根据字典数据获取设备最高优先级状态 + */ +export const getHighestPriorityStatus = (markerData: MarkerData): keyof typeof STATUS_PRIORITY => { + const statuses: Array = [] + + // 检查各种状态 + const gasStatus = getStatusMapping('gas', markerData.gasStatus) + const batteryStatus = getStatusMapping('battery', markerData.batteryStatus) + const fenceStatus = getStatusMapping('fence', markerData.fenceStatus) + const onlineStatus = markerData.onlineStatus === '0' ? 'offline' : null + + // 收集非正常状态 + if (gasStatus && markerData.gasStatus !== '0') + statuses.push(gasStatus as keyof typeof STATUS_PRIORITY) + if (batteryStatus && markerData.batteryStatus !== '0') + statuses.push(batteryStatus as keyof typeof STATUS_PRIORITY) + if (fenceStatus && markerData.fenceStatus !== '0') + statuses.push(fenceStatus as keyof typeof STATUS_PRIORITY) + if (onlineStatus) statuses.push(onlineStatus) + + // 如果没有报警状态,则为正常 + if (statuses.length === 0) return 'normal' + + // 返回优先级最高的状态 + return statuses.reduce((prev, current) => + STATUS_PRIORITY[prev] < STATUS_PRIORITY[current] ? prev : current + ) +} + +/** + * 根据字典数据获取状态颜色 + */ +export const getStatusColor = (status: keyof typeof STATUS_PRIORITY): string => { + if (status === 'normal') return '#67c23a' + if (status === 'offline') return STATUS_DICT.online[0].cssClass + + const [type, value] = status.split('_') as [keyof typeof STATUS_DICT, string] + const info = findStatusInfo(STATUS_DICT[type], value) + return info?.cssClass || '#67c23a' +} + +/** + * 根据字典数据获取状态标签 + */ +export const getStatusLabel = (status: keyof typeof STATUS_PRIORITY): string => { + if (status === 'normal') return '正常' + if (status === 'offline') return STATUS_DICT.online[0].label + + const [type, value] = status.split('_') as [keyof typeof STATUS_DICT, string] + const info = findStatusInfo(STATUS_DICT[type], value) + return info?.label || '正常' +} + +/** + * 创建位置图标SVG + */ +export const createLocationIconSVG = (color: string, size: number = 24) => { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(` + + + + + `)}` +} + +/** + * 创建标记样式 + */ +export const createMarkerStyle = ( + markerData: MarkerData | keyof typeof STATUS_PRIORITY, + isCluster: boolean = false, + clusterSize?: number +) => { + // 如果是字符串,说明是状态值 + const status: keyof typeof STATUS_PRIORITY = + typeof markerData === 'string' + ? (markerData as keyof typeof STATUS_PRIORITY) + : getHighestPriorityStatus(markerData) + + const color = getStatusColor(status) + + if (isCluster && clusterSize) { + // 聚合标记样式 + return new Style({ + image: new Circle({ + radius: Math.min(20 + clusterSize * 2, 40), + fill: new Fill({ + color: color + '80' // 添加透明度 + }), + stroke: new Stroke({ + color: color, + width: 2 + }) + }), + text: new Text({ + text: clusterSize.toString(), + fill: new Fill({ + color: '#ffffff' + }), + font: 'bold 14px Arial' + }) + }) + } else { + // 单个标记样式 - 使用位置图标 + return new Style({ + image: new Icon({ + src: createLocationIconSVG(color, 24), + scale: 1, + anchor: [0.5, 1], // 锚点设置在底部中心 + anchorXUnits: 'fraction', + anchorYUnits: 'fraction' + }) + }) + } +} + +/** + * 生成探测器列表项HTML + */ +export const createDetectorListItem = (detector: DetectorInfo) => ` +
+
+
+
${detector.name}
+
${detector.statusLabel}
+
+
+` + +/** + * 生成聚合标记弹窗HTML + */ +export const createClusterPopupHTML = (detectorList: DetectorInfo[]) => { + const detectorListHTML = detectorList.map(createDetectorListItem).join('') + + return ` +
+ ${detectorListHTML} +
+ ` +} + +/** + * 获取聚合标记数据 + */ +export const getClusterMarkerData = (features: Feature[]): keyof typeof STATUS_PRIORITY => { + // 收集所有标记的状态 + const allStatuses: Array = [] + + features.forEach((feature) => { + const markerData = feature.get('markerData') as MarkerData + if (markerData) { + const status = getHighestPriorityStatus(markerData) + allStatuses.push(status) + } + }) + + // 返回优先级最高的状态 + if (allStatuses.length === 0) return 'normal' + + return allStatuses.reduce((prev, current) => + STATUS_PRIORITY[prev] < STATUS_PRIORITY[current] ? prev : current + ) +} + +/** + * 按优先级排序探测器列表 + */ +export const sortDetectorsByPriority = (detectorList: DetectorInfo[]): DetectorInfo[] => { + return detectorList.sort((a, b) => { + const aPriority = STATUS_ORDER.indexOf(a.status as keyof typeof STATUS_PRIORITY) + const bPriority = STATUS_ORDER.indexOf(b.status as keyof typeof STATUS_PRIORITY) + return aPriority - bPriority + }) +} diff --git a/web/src/views/HandDevice/Home/index.vue b/web/src/views/HandDevice/Home/index.vue new file mode 100644 index 0000000..4db4edf --- /dev/null +++ b/web/src/views/HandDevice/Home/index.vue @@ -0,0 +1,37 @@ + + + diff --git a/web/src/views/Home/Index.vue b/web/src/views/Home/Index.vue index a853ed8..54d57fb 100644 --- a/web/src/views/Home/Index.vue +++ b/web/src/views/Home/Index.vue @@ -1,31 +1,7 @@ diff --git a/web/src/views/Login/components/LoginForm.vue b/web/src/views/Login/components/LoginForm.vue index bd8f70a..9c059f2 100644 --- a/web/src/views/Login/components/LoginForm.vue +++ b/web/src/views/Login/components/LoginForm.vue @@ -16,7 +16,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/views/gas/alarmrule/index.vue b/web/src/views/gas/alarmrule/index.vue new file mode 100644 index 0000000..c27666e --- /dev/null +++ b/web/src/views/gas/alarmrule/index.vue @@ -0,0 +1,268 @@ + + + diff --git a/web/src/views/gas/alarmtype/AlarmTypeForm.vue b/web/src/views/gas/alarmtype/AlarmTypeForm.vue new file mode 100644 index 0000000..17cbed9 --- /dev/null +++ b/web/src/views/gas/alarmtype/AlarmTypeForm.vue @@ -0,0 +1,136 @@ + + diff --git a/web/src/views/gas/alarmtype/index.vue b/web/src/views/gas/alarmtype/index.vue new file mode 100644 index 0000000..99b6298 --- /dev/null +++ b/web/src/views/gas/alarmtype/index.vue @@ -0,0 +1,237 @@ + + + diff --git a/web/src/views/gas/factory/FactoryForm.vue b/web/src/views/gas/factory/FactoryForm.vue new file mode 100644 index 0000000..1ebf47b --- /dev/null +++ b/web/src/views/gas/factory/FactoryForm.vue @@ -0,0 +1,190 @@ + + diff --git a/web/src/views/gas/factory/index.vue b/web/src/views/gas/factory/index.vue new file mode 100644 index 0000000..96b86d3 --- /dev/null +++ b/web/src/views/gas/factory/index.vue @@ -0,0 +1,400 @@ + + + diff --git a/web/src/views/gas/fence/FenceForm.vue b/web/src/views/gas/fence/FenceForm.vue new file mode 100644 index 0000000..033c451 --- /dev/null +++ b/web/src/views/gas/fence/FenceForm.vue @@ -0,0 +1,132 @@ + + diff --git a/web/src/views/gas/fence/index.vue b/web/src/views/gas/fence/index.vue new file mode 100644 index 0000000..f4eb77c --- /dev/null +++ b/web/src/views/gas/fence/index.vue @@ -0,0 +1,243 @@ + + + diff --git a/web/src/views/gas/fencealarm/FenceAlarmForm.vue b/web/src/views/gas/fencealarm/FenceAlarmForm.vue new file mode 100644 index 0000000..c96c4b9 --- /dev/null +++ b/web/src/views/gas/fencealarm/FenceAlarmForm.vue @@ -0,0 +1,194 @@ + + diff --git a/web/src/views/gas/fencealarm/index.vue b/web/src/views/gas/fencealarm/index.vue new file mode 100644 index 0000000..bf82a99 --- /dev/null +++ b/web/src/views/gas/fencealarm/index.vue @@ -0,0 +1,337 @@ + + + diff --git a/web/src/views/gas/gastype/TypeForm.vue b/web/src/views/gas/gastype/TypeForm.vue new file mode 100644 index 0000000..2197efd --- /dev/null +++ b/web/src/views/gas/gastype/TypeForm.vue @@ -0,0 +1,114 @@ + + diff --git a/web/src/views/gas/gastype/index.vue b/web/src/views/gas/gastype/index.vue new file mode 100644 index 0000000..5349ee9 --- /dev/null +++ b/web/src/views/gas/gastype/index.vue @@ -0,0 +1,217 @@ + + + diff --git a/web/src/views/gas/handalarm/HandAlarmForm.vue b/web/src/views/gas/handalarm/HandAlarmForm.vue new file mode 100644 index 0000000..6aeb534 --- /dev/null +++ b/web/src/views/gas/handalarm/HandAlarmForm.vue @@ -0,0 +1,249 @@ + + diff --git a/web/src/views/gas/handalarm/index.vue b/web/src/views/gas/handalarm/index.vue new file mode 100644 index 0000000..8593e75 --- /dev/null +++ b/web/src/views/gas/handalarm/index.vue @@ -0,0 +1,333 @@ + + + diff --git a/web/src/views/gas/handdetector/HandDetectorForm.vue b/web/src/views/gas/handdetector/HandDetectorForm.vue new file mode 100644 index 0000000..dacf1e3 --- /dev/null +++ b/web/src/views/gas/handdetector/HandDetectorForm.vue @@ -0,0 +1,217 @@ + + diff --git a/web/src/views/gas/handdetector/index.vue b/web/src/views/gas/handdetector/index.vue new file mode 100644 index 0000000..a4d1ecd --- /dev/null +++ b/web/src/views/gas/handdetector/index.vue @@ -0,0 +1,249 @@ + + +