Compare commits

...

2 Commits

  1. 4
      readme.md
  2. 1
      web/package.json
  3. 112
      web/pnpm-lock.yaml
  4. 56
      web/src/api/gas/alarmrule/index.ts
  5. 51
      web/src/api/gas/alarmtype/index.ts
  6. 64
      web/src/api/gas/factory/index.ts
  7. 50
      web/src/api/gas/fence/index.ts
  8. 56
      web/src/api/gas/fencealarm/index.ts
  9. 50
      web/src/api/gas/gastype/index.ts
  10. 60
      web/src/api/gas/handalarm/index.ts
  11. 63
      web/src/api/gas/handdetector/index.ts
  12. 7
      web/src/api/gas/index.ts
  13. 4
      web/src/layout/components/Footer/src/Footer.vue
  14. 2
      web/src/locales/zh-CN.ts
  15. 1
      web/src/main.ts
  16. 4
      web/src/store/modules/app.ts
  17. 81
      web/src/store/modules/handDetector.ts
  18. 10
      web/src/utils/dict.ts
  19. 4
      web/src/views/HandDevice/History/index.vue
  20. 139
      web/src/views/HandDevice/Home/components/MapControls.vue
  21. 510
      web/src/views/HandDevice/Home/components/OpenLayerMap.vue
  22. 526
      web/src/views/HandDevice/Home/components/TrajectoryControls.vue
  23. 273
      web/src/views/HandDevice/Home/components/composables/useMapEvents.ts
  24. 297
      web/src/views/HandDevice/Home/components/composables/useMapServices.ts
  25. 139
      web/src/views/HandDevice/Home/components/composables/useMapWatchers.ts
  26. 147
      web/src/views/HandDevice/Home/components/composables/useTrajectoryControls.ts
  27. 69
      web/src/views/HandDevice/Home/components/constants/map.constants.ts
  28. 162
      web/src/views/HandDevice/Home/components/services/animation.service.ts
  29. 264
      web/src/views/HandDevice/Home/components/services/fence-draw.service.ts
  30. 328
      web/src/views/HandDevice/Home/components/services/fence.service.ts
  31. 114
      web/src/views/HandDevice/Home/components/services/map.service.ts
  32. 196
      web/src/views/HandDevice/Home/components/services/marker.service.ts
  33. 86
      web/src/views/HandDevice/Home/components/services/popup.service.ts
  34. 502
      web/src/views/HandDevice/Home/components/services/trajectory.service.ts
  35. 151
      web/src/views/HandDevice/Home/components/types/map.types.ts
  36. 202
      web/src/views/HandDevice/Home/components/utils/map.utils.ts
  37. 37
      web/src/views/HandDevice/Home/index.vue
  38. 28
      web/src/views/Home/Index.vue
  39. 4
      web/src/views/Login/components/LoginForm.vue
  40. 204
      web/src/views/gas/alarmrule/AlarmRuleForm.vue
  41. 268
      web/src/views/gas/alarmrule/index.vue
  42. 136
      web/src/views/gas/alarmtype/AlarmTypeForm.vue
  43. 237
      web/src/views/gas/alarmtype/index.vue
  44. 190
      web/src/views/gas/factory/FactoryForm.vue
  45. 400
      web/src/views/gas/factory/index.vue
  46. 132
      web/src/views/gas/fence/FenceForm.vue
  47. 243
      web/src/views/gas/fence/index.vue
  48. 194
      web/src/views/gas/fencealarm/FenceAlarmForm.vue
  49. 337
      web/src/views/gas/fencealarm/index.vue
  50. 114
      web/src/views/gas/gastype/TypeForm.vue
  51. 217
      web/src/views/gas/gastype/index.vue
  52. 249
      web/src/views/gas/handalarm/HandAlarmForm.vue
  53. 333
      web/src/views/gas/handalarm/index.vue
  54. 217
      web/src/views/gas/handdetector/HandDetectorForm.vue
  55. 249
      web/src/views/gas/handdetector/index.vue

4
readme.md

@ -1,5 +1,9 @@
# 电子锁
## 访问地址
[手持表后台管理线上地址](https://mobile.zdhlcn.com)
## tracup
[手持表 Tracup 地址](https://www.tracup.com/projects/16e1f1fea4b1aadc5373f3bd908de636/list)

1
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",

112
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: {}

56
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 })
}
}

51
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 })
}
}

64
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 })
}
}

50
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 })
}
}

56
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 })
}
}

50
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 })
}
}

60
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 })
}
}

63
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 })
}
}

7
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 }

4
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"
>
<span class="text-14px"
>Copyright ©{{ currentYear }} {{ title }} 京公网安备 11010702002311 Version:{{ appVersion }}
>Copyright ©{{ currentYear }} {{ title }} 京公网安备 11010702002311 Version:{{
appVersion
}}
</span>
</div>
</template>

2
web/src/locales/zh-CN.ts

@ -155,7 +155,7 @@ export default {
},
router: {
login: '登录',
home: '首页',
home: '综合监控',
analysis: '分析页',
workplace: '工作台'
},

1
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'

4
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

81
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
}
}
}
})

10
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:大
}

4
web/src/views/HandDevice/History/index.vue

@ -0,0 +1,4 @@
<template>历史数据</template>
<script setup lang="ts">
defineOptions({ name: 'HandDeviceHistory' })
</script>

139
web/src/views/HandDevice/Home/components/MapControls.vue

@ -0,0 +1,139 @@
<template>
<div class="map-controls">
<el-button
v-if="showMarkers"
:type="isMarkersActive ? 'primary' : 'default'"
@click="$emit('toggle-markers')"
class="control-btn"
>
<el-icon><MapLocation /></el-icon>
</el-button>
<el-button
v-if="showFences"
:type="isFencesActive ? 'primary' : 'default'"
@click="$emit('toggle-fences')"
class="control-btn"
>
<el-icon><Menu /></el-icon>
</el-button>
<el-button
v-if="showTrajectories"
:type="isTrajectoriesActive ? 'primary' : 'default'"
@click="$emit('toggle-trajectories')"
class="control-btn"
>
<el-icon><Timer /></el-icon>
</el-button>
<el-button
v-if="showDrawFences"
:type="isDrawFencesActive ? 'primary' : 'default'"
@click="$emit('toggle-draw-fences')"
class="control-btn"
>
<el-icon><Edit /></el-icon>
</el-button>
</div>
</template>
<script lang="ts" setup>
import { Timer, MapLocation, Menu, Edit } from '@element-plus/icons-vue'
interface Props {
/** 是否显示标记控制按钮 */
showMarkers?: boolean
/** 是否显示围栏控制按钮 */
showFences?: boolean
/** 是否显示轨迹控制按钮 */
showTrajectories?: boolean
/** 是否显示绘制围栏控制按钮 */
showDrawFences?: boolean
/** 标记按钮是否激活 */
isMarkersActive?: boolean
/** 围栏按钮是否激活 */
isFencesActive?: boolean
/** 轨迹按钮是否激活 */
isTrajectoriesActive?: boolean
/** 绘制围栏按钮是否激活 */
isDrawFencesActive?: boolean
}
interface Emits {
(e: 'toggle-markers'): void
(e: 'toggle-fences'): void
(e: 'toggle-trajectories'): void
(e: 'toggle-draw-fences'): void
}
withDefaults(defineProps<Props>(), {
showMarkers: true,
showFences: true,
showTrajectories: true,
showDrawFences: true,
isMarkersActive: false,
isFencesActive: false,
isTrajectoriesActive: false,
isDrawFencesActive: false
})
defineEmits<Emits>()
</script>
<style scoped>
.map-controls {
position: absolute;
padding-left: 20px;
top: 150px;
left: 20px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 1000;
}
.control-btn {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
}
.control-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.control-btn.el-button--primary {
background: #409eff;
}
.control-btn.el-button--primary:hover {
background: #337ecc;
}
.el-button + .el-button {
margin-left: 0;
}
@media (max-width: 768px) {
.map-controls {
flex-direction: row;
padding-left: 0;
top: 10px;
left: 50%;
transform: translateX(-50%);
}
.control-btn {
width: 36px;
height: 36px;
}
}
</style>

510
web/src/views/HandDevice/Home/components/OpenLayerMap.vue

@ -0,0 +1,510 @@
<template>
<div class="map-container" ref="mapContainerRef">
<MapControls
:show-markers="props.showMarkers"
:show-fences="props.showFences"
:show-trajectories="props.showTrajectories"
:show-draw-fences="props.showDrawFences"
:is-markers-active="showMarkers"
:is-fences-active="showFences"
:is-trajectories-active="showTrajectories"
:is-draw-fences-active="showDrawFences"
@toggle-markers="toggleMarkers"
@toggle-fences="toggleFences"
@toggle-trajectories="toggleTrajectories"
@toggle-draw-fences="toggleDrawFences"
/>
<TrajectoryControls
:show-controls="showTrajectories"
:play-state="trajectoryPlayState"
@play="playTrajectory"
@pause="pauseTrajectory"
@stop="stopTrajectory"
@speed-change="setTrajectorySpeed"
@time-change="setTrajectoryTime"
@time-range-change="setTrajectoryTimeRange"
/>
<div class="top-panel" v-show="!appStore.mobile">
<div class="top-panel__left">
<div class="search-group">
<el-input v-model="search" class="search-input" placeholder="请输入关键词" />
</div>
</div>
<div class="top-panel__center">
<div class="data_item">
<div class="data_item__title">手持设备</div>
<div class="data_item__value">2000<span class="data_item__unit"></span></div>
</div>
<div class="data_item">
<div class="data_item__title">在线数量</div>
<div class="data_item__value">200<span class="data_item__unit"></span></div>
</div>
<div class="data_item">
<div class="data_item__title">用户数量</div>
<div class="data_item__value">200<span class="data_item__unit"></span></div>
</div>
<div class="data_item">
<div class="data_item__title">企业数量</div>
<div class="data_item__value">20<span class="data_item__unit"></span></div>
</div>
</div>
<div class="top-panel__right">
<span class="legend-title">报警图例</span>
<div class="normal-legend">正常状态</div>
<div class="alarm1-legend">围栏报警</div>
<div class="alarm2-legend">气体报警</div>
</div>
</div>
<div v-if="panelVisible" class="info-panel">
<div class="info-panel__header">
<span class="info-panel__title">设备详情</span>
<button class="info-panel__close" @click="panelVisible = false">×</button>
</div>
<div class="info-panel__body">
<div v-if="selectedMarker">
<div class="info-panel__name">{{ selectedMarker.name }}</div>
<div class="info-panel__coord"
>坐标{{ selectedMarker.coordinates[0].toFixed(6) }},
{{ selectedMarker.coordinates[1].toFixed(6) }}</div
>
</div>
<div v-else class="info-panel__empty">未选择设备</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { useAppStore } from '@/store/modules/app'
import { useHandDetectorStore } from '@/store/modules/handDetector'
import { ref, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
//
import type { MapProps, MarkerData } from './types/map.types'
//
import { MAP_DEFAULTS, DEFAULT_MARKERS, DEFAULT_FENCES } from './constants/map.constants'
//
import { useMapServices } from './composables/useMapServices'
import { useMapEvents } from './composables/useMapEvents'
import { useTrajectoryControls } from './composables/useTrajectoryControls'
import { useMapWatchers } from './composables/useMapWatchers'
//
import TrajectoryControls from './TrajectoryControls.vue'
import MapControls from './MapControls.vue'
const props = withDefaults(defineProps<MapProps>(), {
tileUrl: MAP_DEFAULTS.tileUrl,
center: () => MAP_DEFAULTS.center,
zoom: MAP_DEFAULTS.zoom,
maxZoom: MAP_DEFAULTS.maxZoom,
minZoom: MAP_DEFAULTS.minZoom,
markers: () => DEFAULT_MARKERS,
fences: () => DEFAULT_FENCES,
enableCluster: MAP_DEFAULTS.enableCluster,
clusterDistance: MAP_DEFAULTS.clusterDistance,
showTrajectories: true,
showMarkers: true,
showFences: true,
showDrawFences: true
})
//
const showMarkers = ref(props.showMarkers)
const showTrajectories = ref(false)
const showFences = ref(false)
const showDrawFences = ref(false)
const mapContainerRef = ref<HTMLElement | null>(null)
const handDetectorStore = useHandDetectorStore()
//
const appStore = useAppStore()
const panelVisible = ref(false)
const selectedMarker = ref<MarkerData | null>(null)
const search = ref('')
// 使
const {
services,
layerRefs,
initializeServices,
initializeMapAndLayers,
setMarkersVisible,
setTrajectoriesVisible,
setFencesVisible,
toggleFenceDrawing,
clearFenceDrawLayer,
updateMarkers,
refreshMarkerStyles
} = useMapServices()
const {
trajectoryPlayState,
playTrajectory,
pauseTrajectory,
stopTrajectory,
setTrajectorySpeed,
setTrajectoryTime,
setTrajectoryTimeRange,
setupTrajectoryWatcher,
cleanup: cleanupTrajectory
} = useTrajectoryControls()
const { setupMapEventListeners } = useMapEvents()
//
const toggleTrajectories = () => {
if (showTrajectories.value && trajectoryPlayState.value.isPlaying) {
cleanupTrajectory()
}
showTrajectories.value = !showTrajectories.value
}
const toggleMarkers = () => {
showMarkers.value = !showMarkers.value
}
const toggleFences = () => {
showFences.value = !showFences.value
}
const toggleDrawFences = () => {
showDrawFences.value = !showDrawFences.value
if (showDrawFences.value) {
//
toggleFenceDrawing(true, handleFenceDrawComplete)
} else {
//
toggleFenceDrawing(false)
}
}
import { MapService } from './services/map.service'
let mapService: MapService | null = null
let isMapInitialized = false
/**
* 初始化地图
*/
const initMap = () => {
if (!mapContainerRef.value) return
//
mapService = new MapService()
//
try {
//
initializeServices()
const mapInstance = mapService.initMap(mapContainerRef.value, props)
//
const { map, popupOverlay } = initializeMapAndLayers(mapInstance, props)
//
setMarkersVisible(showMarkers.value)
setTrajectoriesVisible(showTrajectories.value, props.markers)
setFencesVisible(showFences.value)
//
setupMapEventListeners(
map,
popupOverlay,
services.trajectoryService as any,
services.popupService,
{
isDrawing: () => !!services.fenceDrawService?.isCurrentlyDrawing?.(),
onMarkerClick: (marker: MarkerData) => {
selectedMarker.value = marker
panelVisible.value = true
},
markerLayer: layerRefs.value?.markerLayer,
refreshMarkerStyles
}
)
//
setupTrajectoryWatcher(services.trajectoryService as any, showTrajectories)
//
const { setupAllWatchers } = useMapWatchers({
showMarkers,
showTrajectories,
showFences,
showDrawFences,
setMarkersVisible,
setTrajectoriesVisible,
setFencesVisible,
toggleFenceDrawing,
updateMarkers,
markers: props.markers || []
})
setupAllWatchers()
//
isMapInitialized = true
console.log('地图初始化成功', { map, services: services })
} catch (error) {
console.error('地图初始化失败:', error)
}
}
/**
* 处理围栏绘制完成
*/
const handleFenceDrawComplete = (coordinates: [number, number][]) => {
if (coordinates.length < 3) {
// 3
ElMessage.warning('围栏至少需要3个点才能形成有效区域')
return
}
console.log('围栏绘制完成:', coordinates)
clearFenceDrawLayer()
//
showDrawFences.value = false
}
// markers props
watch(
() => props.markers,
(newMarkers) => {
if (newMarkers && newMarkers.length > 0 && isMapInitialized) {
updateMarkers(newMarkers, props)
}
},
{ deep: true, immediate: false }
)
onMounted(() => {
setTimeout(() => {
initMap()
}, 100)
})
</script>
<style scoped>
.map-container {
width: 100%;
height: calc(100vh - 120px);
}
:deep(.ol-viewport) {
width: 100% !important;
height: 100% !important;
}
:deep(.ol-map) {
width: 100% !important;
height: 100% !important;
}
.info-panel {
position: absolute;
top: 100px;
right: 20px;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
width: 300px;
max-height: 80vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.info-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
border-bottom: 1px solid #eee;
background-color: #f5f5f5;
border-radius: 8px 8px 0 0;
}
.info-panel__title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.info-panel__close {
background-color: #ff4d4f;
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 18px;
font-weight: bold;
transition: background-color 0.3s ease;
}
.info-panel__close:hover {
background-color: #d9363e;
}
.info-panel__body {
padding: 15px;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.info-panel__empty {
text-align: center;
color: #888;
padding: 20px;
}
.info-panel__name {
font-size: 18px;
font-weight: bold;
margin-bottom: 5px;
color: #555;
}
.info-panel__coord {
font-size: 14px;
color: #666;
}
/* 顶部面板样式 */
.top-panel {
position: absolute;
top: 12px;
left: 12px;
right: 12px;
z-index: 1000;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
flex-wrap: wrap;
height: 100px;
box-sizing: border-box;
}
.top-panel__left {
flex: 0 0 260px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 10px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
height: 100%;
display: flex;
align-items: center;
}
.search-group {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
}
.search-type {
flex: 0 0 120px;
}
.search-input {
width: 220px;
}
.top-panel__center {
flex: 1 1 auto;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
align-items: center;
}
.data_item {
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 8px;
padding: 8px 12px;
min-height: 56px;
display: flex;
flex-direction: column;
justify-content: center;
}
.data_item__title {
font-size: 12px;
color: #909399;
}
.data_item__value {
font-size: 18px;
font-weight: 600;
color: #303133;
margin-top: 4px;
}
.data_item__unit {
font-size: 12px;
color: #909399;
margin-left: 6px;
}
.top-panel__right {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(0, 0, 0, 0.06);
padding: 8px 12px;
border-radius: 8px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
white-space: nowrap;
height: 100%;
}
.legend-title {
color: #606266;
}
.normal-legend,
.alarm1-legend,
.alarm2-legend {
padding: 4px 8px;
border-radius: 999px;
color: #fff;
font-size: 12px;
line-height: 1;
}
.normal-legend {
background: #67c23a;
}
.alarm1-legend {
background: #e6a23c;
}
.alarm2-legend {
background: #f56c6c;
}
@media (max-width: 992px) {
.top-panel__left {
flex: 1 1 100%;
}
.top-panel__center {
flex: 1 1 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.top-panel__right {
flex: 1 1 100%;
justify-content: flex-start;
}
}
@media (max-width: 600px) {
.top-panel__center {
grid-template-columns: 1fr;
}
}
</style>

526
web/src/views/HandDevice/Home/components/TrajectoryControls.vue

@ -0,0 +1,526 @@
<template>
<div class="trajectory-controls" v-if="showControls">
<!-- 主控制面板 -->
<div class="control-panel">
<!-- 播放控制按钮区域 -->
<div class="play-controls">
<el-button
:type="playState.isPlaying ? 'warning' : 'primary'"
:icon="playState.isPlaying ? VideoPause : VideoPlay"
circle
size="small"
@click="handlePlayPause"
/>
<el-button
v-if="playState.isPlaying"
type="danger"
:icon="SwitchButton"
circle
size="small"
@click="handleStop"
/>
</div>
<!-- 时间范围选择器 -->
<div class="time-range-controls" v-if="!appStore.mobile">
<el-button
:icon="Calendar"
size="small"
:type="showTimeRangePicker ? 'primary' : 'default'"
@click="toggleTimeRangePicker"
class="time-range-btn"
>
时间选择
</el-button>
</div>
<!-- 可折叠的时间范围选择器 -->
<div v-if="showTimeRangePicker && !appStore.mobile" class="time-range-picker">
<el-date-picker
v-model="timeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
format="MM-DD HH:mm"
value-format="x"
size="small"
@change="handleTimeRangeChange"
:clearable="false"
:shortcuts="shortcuts"
:unlink-panels="true"
:editable="false"
/>
</div>
<div v-if="appStore.mobile" class="time-range-picker">
<el-button
@click="
handleTimeRangeChange([dayjs().subtract(5, 'minute').valueOf(), dayjs().valueOf()])
"
>近5分钟</el-button
>
<el-button
@click="
handleTimeRangeChange([dayjs().subtract(10, 'minute').valueOf(), dayjs().valueOf()])
"
>近10分钟</el-button
>
</div>
<!-- 播放速度选择器 -->
<div class="speed-controls">
<el-select
size="default"
v-model="currentSpeed"
style="width: 80px"
@change="handleSpeedChange"
>
<el-option
v-for="speed in speedOptions"
:key="speed.value"
:label="speed.label"
:value="speed.value"
/>
</el-select>
</div>
</div>
<!-- 时间轴 -->
<div class="timeline-container">
<div class="time-display">
{{ formatTime(playState.currentTime) }}
</div>
<div class="slider-container">
<el-slider
v-model="currentProgress"
:min="playState.startTime || 0"
:max="playState.endTime || Date.now()"
:show-tooltip="false"
@change="handleTimeChange"
size="small"
/>
</div>
<div class="time-display">
{{ formatTime(playState.endTime || Date.now()) }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue'
import dayjs from 'dayjs'
import type { TrajectoryPlayState } from './types/map.types'
import { VideoPlay, VideoPause, SwitchButton, Calendar } from '@element-plus/icons-vue'
import { useAppStore } from '@/store/modules/app'
interface Props {
/** 是否显示控制面板 */
showControls?: boolean
/** 播放状态 */
playState?: TrajectoryPlayState
}
interface Emits {
(e: 'play'): void
(e: 'pause'): void
(e: 'stop'): void
(e: 'speed-change', speed: number): void
(e: 'time-change', timestamp: number): void
(e: 'time-range-change', range: { startTime: number; endTime: number }): void
}
const props = withDefaults(defineProps<Props>(), {
showControls: true,
playState: () => ({
isPlaying: false,
currentTime: dayjs().subtract(1, 'day').valueOf(),
speed: 1,
startTime: dayjs().subtract(1, 'day').valueOf(),
endTime: dayjs().valueOf()
})
})
const emit = defineEmits<Emits>()
const appStore = useAppStore()
//
const speedOptions = [
{ label: '0.5x', value: 0.5 },
{ label: '1x', value: 1 },
{ label: '2x', value: 2 },
{ label: '4x', value: 4 },
{ label: '8x', value: 8 }
]
//
const showTimeRangePicker = ref(false)
const currentSpeed = ref(props.playState.speed)
const timeRange = ref<[number, number]>([
props.playState.startTime || dayjs().subtract(1, 'day').valueOf(),
props.playState.endTime || dayjs().valueOf()
])
//
const shortcuts = [
{
text: '最近1小时',
value: () => {
const end = dayjs().valueOf()
const start = dayjs(end).subtract(1, 'hour').valueOf()
return [start, end]
}
},
{
text: '最近3小时',
value: () => {
const end = dayjs().valueOf()
const start = dayjs(end).subtract(3, 'hour').valueOf()
return [start, end]
}
},
{
text: '最近6小时',
value: () => {
const end = dayjs().valueOf()
const start = dayjs(end).subtract(6, 'hour').valueOf()
return [start, end]
}
},
{
text: '最近24小时',
value: () => {
const end = dayjs().valueOf()
const start = dayjs(end).subtract(24, 'hour').valueOf()
return [start, end]
}
},
{
text: '最近7天',
value: () => {
const end = dayjs().valueOf()
const start = dayjs(end).subtract(7, 'day').valueOf()
return [start, end]
}
}
]
//
const currentProgress = computed({
get: () => props.playState.currentTime,
set: (value: number) => {
emit('time-change', value)
}
})
//
const handlePlayPause = () => {
if (props.playState.isPlaying) {
emit('pause')
} else {
emit('play')
}
}
const handleStop = () => {
emit('stop')
}
const handleSpeedChange = (speed: number) => {
currentSpeed.value = speed
emit('speed-change', speed)
}
const handleTimeChange = (timestamp: number) => {
emit('time-change', timestamp)
}
const handleTimeRangeChange = (range: [number, number] | null) => {
if (range && range.length === 2) {
timeRange.value = range
emit('time-range-change', {
startTime: range[0],
endTime: range[1]
})
}
}
const toggleTimeRangePicker = () => {
showTimeRangePicker.value = !showTimeRangePicker.value
}
//
const formatTime = (timestamp: number): string => {
return dayjs(timestamp).format('MM-DD HH:mm:ss')
}
//
watch(
() => props.playState.speed,
(newSpeed) => {
currentSpeed.value = newSpeed
}
)
watch(
() => [props.playState.startTime, props.playState.endTime],
([startTime, endTime]) => {
if (startTime && endTime) {
timeRange.value = [startTime, endTime]
}
},
{ deep: true }
)
</script>
<style scoped>
.trajectory-controls {
position: absolute;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 10px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255, 255, 255, 0.2);
min-width: 480px;
z-index: 1000;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 暗色主题样式 */
.dark .trajectory-controls {
background: rgba(31, 41, 55, 0.95);
border: 1px solid rgba(75, 85, 99, 0.3);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.control-panel {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
.play-controls {
display: flex;
gap: 8px;
}
.time-range-controls .time-range-btn {
font-size: 12px;
}
.time-range-controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.speed-controls {
margin-left: auto;
}
.timeline-container {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.time-display {
font-size: 12px;
color: #666;
font-family: monospace;
min-width: 80px;
text-align: center;
transition: color 0.3s ease;
}
/* 暗色主题下的时间显示 */
.dark .time-display {
color: #9ca3af;
}
.slider-container {
flex: 1;
}
.time-range-picker {
padding-top: 4px;
display: flex;
justify-content: center;
}
/* 响应式设计 */
@media (max-width: 768px) {
.trajectory-controls {
min-width: 320px;
left: 10px;
right: 10px;
transform: none;
padding: 12px;
}
.control-panel {
gap: 16px;
}
/* 取消外部快捷按钮后,此块无须特殊处理 */
.speed-controls {
margin-left: 0;
align-self: stretch;
}
.time-display {
font-size: 11px;
min-width: 70px;
}
}
@media (max-width: 480px) {
.trajectory-controls {
min-width: unset;
width: calc(100% - 20px);
}
.timeline-container {
gap: 16px;
}
.slider-container {
width: 100%;
}
.time-display {
min-width: unset;
}
}
/* 自定义滑块样式 */
:deep(.el-slider__runway) {
background-color: #e4e7ed;
height: 6px;
transition: background-color 0.3s ease;
}
:deep(.el-slider__bar) {
height: 6px;
transition: background 0.3s ease;
}
:deep(.el-slider__button) {
width: 16px;
height: 16px;
border: 2px solid #409eff;
background-color: #fff;
transition: all 0.3s ease;
}
:deep(.el-slider__button:hover) {
transform: scale(1.2);
}
/* 暗色主题下的滑块样式 */
.dark :deep(.el-slider__runway) {
background-color: #374151;
}
:global(.dark) :deep(.el-slider__button) {
border: 2px solid #3b82f6;
background-color: #1f2937;
}
.dark :deep(.el-slider__button:hover) {
background-color: #374151;
}
/* 美化按钮样式 */
:deep(.el-button.is-circle) {
padding: 8px;
transition: all 0.3s ease;
}
:deep(.el-button--primary.is-circle) {
border: none;
}
:deep(.el-button--warning.is-circle) {
border: none;
}
:deep(.el-button--danger.is-circle) {
border: none;
}
/* 日期选择器和下拉框的暗色主题优化 */
.dark :deep(.el-date-editor) {
background-color: #374151;
border-color: #4b5563;
color: #e5e7eb;
}
.dark :deep(.el-date-editor:hover) {
border-color: #6b7280;
}
.dark :deep(.el-date-editor.is-focus) {
border-color: #3b82f6;
}
.dark :deep(.el-date-editor .el-input__inner) {
background-color: transparent;
color: #e5e7eb;
}
.dark :deep(.el-date-editor .el-input__inner::placeholder) {
color: #9ca3af;
}
.dark :deep(.el-select .el-input__inner) {
background-color: #374151;
border-color: #4b5563;
color: #e5e7eb;
}
.dark :deep(.el-select .el-input__inner:hover) {
border-color: #6b7280;
}
.dark :deep(.el-select .el-input__inner:focus) {
border-color: #3b82f6;
}
/* 毛玻璃效果增强 */
.trajectory-controls::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: inherit;
border-radius: inherit;
backdrop-filter: blur(20px) saturate(180%);
z-index: -1;
}
/* 暗色主题下的边框光效 */
.dark .trajectory-controls::after {
content: '';
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
border-radius: inherit;
z-index: -2;
opacity: 0;
transition: opacity 0.3s ease;
}
.dark .trajectory-controls:hover::after {
opacity: 1;
}
</style>

273
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 `
<div style="font-size: 12px; color: #333;">
<div style="font-weight: bold; margin-bottom: 4px;">${deviceName}</div>
<div>时间: ${timeText}</div>
<div style="font-size: 10px; color: #666;">
${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}
</div>
</div>
`
},
handleTrajectoryLine: (feature: any): string => {
const deviceId = feature.get('deviceId') || ''
const deviceName =
trajectoryService?.getTrajectoryData().find((t) => t.deviceId === deviceId)?.name ||
deviceId
return `
<div style="font-size: 12px; color: #333;">
<div style="font-weight: bold;">${deviceName} - </div>
</div>
`
},
handleFence: (feature: any): string => {
const fenceData = feature.get('fenceData')
const statusText =
fenceData.status === 0 ? '正常' : fenceData.status === 1 ? '一级报警' : '二级报警'
const typeText = fenceData.type === 0 ? '包含' : '排斥'
return `
<div style="font-size: 12px; color: #333;">
<div style="font-weight: bold; margin-bottom: 4px;">${fenceData.name}</div>
<div>状态: ${statusText}</div>
<div>类型: ${typeText}</div>
<div style="font-size: 10px; color: #666; margin-top: 2px;">
${fenceData.remark || '无备注'}
</div>
</div>
`
},
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
}
}

297
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<ServiceInstances>({
mapService: null,
markerService: null,
animationService: null,
popupService: null,
trajectoryService: null,
fenceService: null,
fenceDrawService: null
})
// 图层引用状态
const layerRefs = ref<LayerRefs | null>(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
}
}

139
web/src/views/HandDevice/Home/components/composables/useMapWatchers.ts

@ -0,0 +1,139 @@
/**
* composable
*/
import { watch, type Ref } from 'vue'
interface WatchOptions {
showMarkers: Ref<boolean>
showTrajectories: Ref<boolean>
showFences: Ref<boolean>
showDrawFences: Ref<boolean>
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
}
}

147
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<TrajectoryPlayState>({
isPlaying: false,
currentTime: dayjs().subtract(1, 'day').valueOf(),
speed: 1,
startTime: dayjs().subtract(1, 'day').valueOf(),
endTime: dayjs().valueOf()
})
// 轨迹播放定时器
const trajectoryPlayTimer = ref<number | null>(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
}
}

69
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<keyof typeof STATUS_PRIORITY>
// 默认标记数据
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[]

162
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<VectorSource> | null = null
private animationTimer: number | null = null
private map: any = null
private enableCluster: boolean = true
/**
*
*/
createRippleLayer(
markers: MarkerData[],
map: any,
enableCluster: boolean = true
): VectorLayer<VectorSource> {
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<VectorSource> | null {
return this.rippleLayer
}
/**
*
*/
destroy(): void {
this.stopAnimation()
this.rippleLayer = null
this.map = null
}
}

264
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<VectorSource> | 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
}
}

328
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<VectorSource> | null = null
private fenceData: FenceData[] = []
private map: any = null
private isVisible: boolean = true
/**
*
*/
createFenceLayer(fences: FenceData[], map: any): VectorLayer<VectorSource> {
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<VectorSource> | 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
}
}

114
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<XYZ | OSM> | null = null
private popupOverlay: Overlay | null = null
/**
*
*/
private createTileLayer(props: MapProps): TileLayer<XYZ | OSM> {
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
}
}
}

196
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<VectorSource | Cluster> | null = null
private currentProps: MapProps | null = null
private map: any = null
/**
*
*/
setMap(map: any): void {
this.map = map
}
/**
*
*/
createMarkerLayer(props: MapProps, map?: any): VectorLayer<VectorSource | Cluster> {
// 保存地图实例
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<VectorSource | Cluster> | 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<VectorSource | Cluster> {
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
}
}

86
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 `
<div style="font-weight: bold; margin-bottom: 4px;">${markerData.name}</div>
<div style="color: ${getStatusColor(status)};">
状态: ${getStatusLabel(status)}
</div>
<div style="margin-top: 4px; font-size: 10px; color: #666;">
坐标: ${markerData.coordinates[0].toFixed(6)}, ${markerData.coordinates[1].toFixed(6)}
</div>
`
}
/**
*
*/
handleDefaultPopup(): string {
return `
<div style="font-weight: bold;"></div>
<div style="font-size: 10px; color: #666;"></div>
`
}
/**
*
*/
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()
}
}

502
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<VectorSource> | null = null
private trajectoryData: TrajectoryData[] = []
private map: any = null
private animationTimer: number | null = null
// 当前移动的 marker 图层
private movingMarkerLayer: VectorLayer<VectorSource> | null = null
// 按时间排序的所有轨迹点
private sortedTrajectoryPoints: Array<TrajectoryPoint> = []
/**
*
*/
createTrajectoryLayer(map: any): VectorLayer<VectorSource> {
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<TrajectoryPoint> {
const result: Array<TrajectoryPoint> = []
// 为每个设备找到对应时间点的最近轨迹点
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<TrajectoryPoint>): 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<VectorSource> | null {
return this.trajectoryLayer
}
/**
*
*/
getMovingMarkerLayer(): VectorLayer<VectorSource> | 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 = []
}
}

151
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
}

202
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<keyof typeof STATUS_PRIORITY> = []
// 检查各种状态
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(`
<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" fill="${color}"/>
<circle cx="12" cy="9" r="2" fill="white"/>
</svg>
`)}`
}
/**
*
*/
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) => `
<div style="display: flex; align-items: center; padding: 6px 0; border-bottom: 1px solid #f0f0f0;">
<div style="width: 10px; height: 10px; border-radius: 50%; background-color: ${detector.statusColor}; margin-right: 10px; flex-shrink: 0;"></div>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; font-size: 13px; color: #333; margin-bottom: 2px;">${detector.name}</div>
<div style="color: ${detector.statusColor}; font-size: 11px; font-weight: 400;">${detector.statusLabel}</div>
</div>
</div>
`
/**
* HTML
*/
export const createClusterPopupHTML = (detectorList: DetectorInfo[]) => {
const detectorListHTML = detectorList.map(createDetectorListItem).join('')
return `
<div style="max-height: 250px; overflow-y: auto; padding-right: 4px;">
${detectorListHTML}
</div>
`
}
/**
*
*/
export const getClusterMarkerData = (features: Feature[]): keyof typeof STATUS_PRIORITY => {
// 收集所有标记的状态
const allStatuses: Array<keyof typeof STATUS_PRIORITY> = []
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
})
}

37
web/src/views/HandDevice/Home/index.vue

@ -0,0 +1,37 @@
<template>
<OpenLayerMap v-if="inited" :markers="markers" />
</template>
<script lang="ts" setup>
import OpenLayerMap from './components/OpenLayerMap.vue'
import { getLastestDetectorData } from '@/api/gas'
import { HandDetector } from '@/api/gas/handdetector'
import { MarkerData } from './components/types/map.types'
const getDataTimer = ref<NodeJS.Timeout | null>(null)
const markers = ref<MarkerData[]>([])
const inited = ref(false)
const getMarkers = async () => {
console.log('getMarkers')
return await getLastestDetectorData().then((res: HandDetector[]) => {
res = res.filter((i) => i.enableStatus === 1)
res = res.map((i) => {
return {
...i,
coordinates: [i.longitude, i.latitude],
data: []
}
})
markers.value = res as unknown as any[]
inited.value = true
})
}
onMounted(() => {
getMarkers()
getDataTimer.value = setInterval(() => {
getMarkers()
}, 5000)
})
onUnmounted(() => {
clearInterval(getDataTimer.value as NodeJS.Timeout)
})
</script>
<style scoped></style>

28
web/src/views/Home/Index.vue

@ -1,31 +1,7 @@
<template>
<div>
<el-card shadow="never">
<el-skeleton :loading="loading" animated>
<el-row :gutter="16" justify="space-between">
<el-col :xl="12" :lg="12" :md="12" :sm="24" :xs="24">
<div class="flex items-center">
<el-avatar :src="avatar" :size="70" class="mr-16px">
<img src="@/assets/imgs/user.png" alt="" />
</el-avatar>
<div>
<div class="text-20px">
{{ t('workplace.welcome') }} {{ username }} {{ t('workplace.happyDay') }}
</div>
</div>
</div>
</el-col>
</el-row>
</el-skeleton>
</el-card>
</div>
<HandDeviceHome />
</template>
<script lang="ts" setup>
import { useUserStore } from '@/store/modules/user'
import HandDeviceHome from '../HandDevice/Home/index.vue'
defineOptions({ name: 'Index' })
const { t } = useI18n()
const userStore = useUserStore()
const loading = ref(false)
const avatar = userStore.getUser.avatar
const username = userStore.getUser.nickname
</script>

4
web/src/views/Login/components/LoginForm.vue

@ -16,7 +16,7 @@
</el-form-item>
</el-col>
<!-- 租户 -->
<!-- <el-col :span="24" class="px-10px">
<el-col :span="24" class="px-10px">
<el-form-item v-if="loginData.tenantEnable === 'true'" prop="tenantName">
<el-input
v-model="loginData.loginForm.tenantName"
@ -26,7 +26,7 @@
type="primary"
/>
</el-form-item>
</el-col> -->
</el-col>
<el-col :span="24" class="px-10px">
<el-form-item prop="username">
<el-input

204
web/src/views/gas/alarmrule/AlarmRuleForm.vue

@ -0,0 +1,204 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
v-loading="formLoading"
>
<el-form-item label="气体类型" prop="gasTypeId">
<el-select v-model="formData.gasTypeId" placeholder="请选择气体类型">
<el-option
v-for="item in props.gasTypes"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="警报类型" prop="alarmTypeId">
<el-select
v-model="formData.alarmTypeId"
placeholder="请选择警报类型"
@change="handleAlarmTypeIdChange"
>
<el-option
v-for="item in props.alarmTypes"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="警报名称" prop="alarmName">
<el-input v-model="formData.alarmName" placeholder="请输入警报名称" />
</el-form-item>
<el-form-item label="警报名称颜色" prop="alarmNameColor">
<el-color-picker v-model="formData.alarmNameColor" :disabled="true" />
</el-form-item>
<el-form-item label="警报颜色" prop="alarmColor">
<el-color-picker
v-model="formData.alarmColor"
placeholder="请输入警报颜色"
:disabled="true"
/>
</el-form-item>
<el-form-item label="警报方式/级别" prop="alarmLevel">
<el-select v-model="formData.alarmLevel" placeholder="请输入警报方式/级别" :disabled="true">
<el-option
v-for="item in getIntDictOptions(DICT_TYPE.HAND_DETECTOR_ALARM_LEVEL)"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="触发值(小)" prop="min">
<el-input-number
v-model="formData.min"
placeholder="请输入触发值(小)"
:controls="false"
/>
</el-form-item>
<el-form-item label="触发值(大)" prop="max">
<el-input-number
v-model="formData.max"
placeholder="请输入触发值(大)"
:controls="false"
/>
</el-form-item>
<el-form-item label="最值方向" prop="direction">
<el-select v-model="formData.direction" placeholder="请输入最值方向">
<el-option
v-for="item in getIntDictOptions(DICT_TYPE.HAND_DETECTOR_VALUE_DIRECTION)"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input v-model="formData.sortOrder" placeholder="请输入排序" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { AlarmRuleApi, AlarmRule } from '@/api/gas/alarmrule'
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
/** GAS警报规则 表单 */
defineOptions({ name: 'AlarmRuleForm' })
const { t } = useI18n() //
const message = useMessage() //
const props = defineProps({
gasTypes: {
type: Array as PropType<any[]>,
required: true
},
alarmTypes: {
type: Array as PropType<any[]>,
required: true
}
})
const dialogVisible = ref(false) //
const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const formType = ref('') // create - update -
const formData = ref({
id: undefined,
gasTypeId: undefined,
alarmTypeId: undefined,
alarmName: undefined,
alarmNameColor: undefined,
alarmColor: undefined,
alarmLevel: undefined,
min: undefined,
max: undefined,
direction: undefined,
sortOrder: undefined,
remark: undefined
})
const formRules = reactive({
gasTypeId: [{ required: true, message: '气体类型不能为空', trigger: 'blur' }],
alarmTypeId: [{ required: true, message: '警报类型不能为空', trigger: 'blur' }],
alarmName: [{ required: true, message: '警报名称不能为空', trigger: 'blur' }],
direction: [{ required: true, message: '最值方向不能为空', trigger: 'blur' }]
})
const formRef = ref() // Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
//
if (id) {
formLoading.value = true
try {
formData.value = await AlarmRuleApi.getAlarmRule(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // open
/** 提交表单 */
const emit = defineEmits(['success']) // success
const submitForm = async () => {
//
await formRef.value.validate()
//
formLoading.value = true
try {
const data = formData.value as unknown as AlarmRule
if (formType.value === 'create') {
await AlarmRuleApi.createAlarmRule(data)
message.success(t('common.createSuccess'))
} else {
await AlarmRuleApi.updateAlarmRule(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
//
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
gasTypeId: undefined,
alarmTypeId: undefined,
alarmName: undefined,
alarmNameColor: undefined,
alarmColor: undefined,
alarmLevel: undefined,
min: undefined,
max: undefined,
direction: undefined,
sortOrder: undefined,
remark: undefined
}
formRef.value?.resetFields()
}
/** 警报类型改变 */
const handleAlarmTypeIdChange = (value: number) => {
formData.value.alarmName = props.alarmTypes.find((item) => item.id === value)?.name
formData.value.alarmNameColor = props.alarmTypes.find((item) => item.id === value)?.nameColor
}
</script>

268
web/src/views/gas/alarmrule/index.vue

@ -0,0 +1,268 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="120px"
>
<el-form-item label="气体类型" prop="gasTypeId">
<el-select
v-model="queryParams.gasTypeId"
placeholder="请选择气体类型"
clearable
filterable
@keyup.enter="handleQuery"
class="!w-240px"
>
<el-option
v-for="item in handDetectorStore.getGasTypes"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="警报类型" prop="alarmTypeId">
<el-select
v-model="queryParams.alarmTypeId"
placeholder="请选择警报类型"
filterable
clearable
@keyup.enter="handleQuery"
class="!w-240px"
>
<el-option
v-for="item in handDetectorStore.getAlarmTypes"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['gas:alarm-rule:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
<el-button
type="success"
plain
@click="handleExport"
:loading="exportLoading"
v-hasPermi="['gas:alarm-rule:export']"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
<el-button
type="danger"
plain
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-hasPermi="['gas:alarm-rule:delete']"
>
<Icon icon="ep:delete" class="mr-5px" /> 批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table
row-key="id"
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleRowCheckboxChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="气体类型" align="center" prop="gasTypeId">
<template #default="scope">
{{ handDetectorStore.getGasTypes.find((item) => item.id === scope.row.gasTypeId)?.name }}
</template>
</el-table-column>
<el-table-column label="警报类型" align="center" prop="alarmTypeId">
<template #default="scope">
{{
handDetectorStore.getAlarmTypes.find((item) => item.id === scope.row.alarmTypeId)?.name
}}
</template>
</el-table-column>
<el-table-column label="警报名称" align="center" prop="alarmName" />
<el-table-column label="警报方式/级别" align="center" prop="alarmLevel" />
<el-table-column label="触发值(小)" align="center" prop="min" />
<el-table-column label="触发值(大)" align="center" prop="max" />
<el-table-column label="最值方向" align="center" prop="direction" />
<el-table-column label="排序" align="center" prop="sortOrder" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center" min-width="120px">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['gas:alarm-rule:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['gas:alarm-rule:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<AlarmRuleForm
ref="formRef"
@success="getList"
:gasTypes="handDetectorStore.getGasTypes"
:alarmTypes="handDetectorStore.getAlarmTypes"
/>
</template>
<script setup lang="ts">
import { isEmpty } from '@/utils/is'
import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import { AlarmRuleApi, AlarmRule } from '@/api/gas/alarmrule'
import AlarmRuleForm from './AlarmRuleForm.vue'
import { useHandDetectorStore } from '@/store/modules/handDetector'
/** GAS警报规则 列表 */
defineOptions({ name: 'AlarmRule' })
const message = useMessage() //
const { t } = useI18n() //
const handDetectorStore = useHandDetectorStore()
const loading = ref(true) //
const list = ref<AlarmRule[]>([]) //
const total = ref(0) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
gasTypeId: undefined,
alarmTypeId: undefined,
alarmName: undefined,
alarmNameColor: undefined,
alarmColor: undefined,
alarmLevel: undefined,
min: undefined,
max: undefined,
direction: undefined,
sortOrder: undefined,
remark: undefined,
createTime: []
})
const queryFormRef = ref() //
const exportLoading = ref(false) //
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await AlarmRuleApi.getAlarmRulePage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
//
await message.delConfirm()
//
await AlarmRuleApi.deleteAlarmRule(id)
message.success(t('common.delSuccess'))
//
await getList()
} catch {}
}
/** 批量删除GAS警报规则 */
const handleDeleteBatch = async () => {
try {
//
await message.delConfirm()
await AlarmRuleApi.deleteAlarmRuleList(checkedIds.value)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (records: AlarmRule[]) => {
checkedIds.value = records.map((item) => item.id)
}
/** 导出按钮操作 */
const handleExport = async () => {
try {
//
await message.exportConfirm()
//
exportLoading.value = true
const data = await AlarmRuleApi.exportAlarmRule(queryParams)
download.excel(data, 'GAS警报规则.xls')
} catch {
} finally {
exportLoading.value = false
}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

136
web/src/views/gas/alarmtype/AlarmTypeForm.vue

@ -0,0 +1,136 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
v-loading="formLoading"
>
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="名称颜色" prop="nameColor">
<el-color-picker v-model="formData.nameColor" />
</el-form-item>
<el-form-item label="颜色" prop="color">
<el-color-picker v-model="formData.color" />
</el-form-item>
<el-form-item label="警报方式/级别" prop="level">
<el-select v-model="formData.level" placeholder="请选择警报方式/级别">
<el-option
v-for="item in props.alarmLevels"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input v-model="formData.sortOrder" placeholder="请输入排序" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { AlarmTypeApi, AlarmType } from '@/api/gas/alarmtype'
/** GAS警报类型 表单 */
defineOptions({ name: 'AlarmTypeForm' })
const { t } = useI18n() //
const message = useMessage() //
const props = defineProps({
alarmLevels: {
type: Array as PropType<any[]>,
required: true
}
})
const dialogVisible = ref(false) //
const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const formType = ref('') // create - update -
const formData = ref({
id: undefined,
name: undefined,
nameColor: '#000',
color: undefined,
level: undefined,
sortOrder: undefined,
remark: undefined
})
const formRules = reactive({
name: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
color: [{ required: true, message: '颜色不能为空', trigger: 'blur' }],
level: [
{
required: true,
message: '警报方式/级别不能为空',
trigger: 'blur'
}
]
})
const formRef = ref() // Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
//
if (id) {
formLoading.value = true
try {
formData.value = await AlarmTypeApi.getAlarmType(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // open
/** 提交表单 */
const emit = defineEmits(['success']) // success
const submitForm = async () => {
//
await formRef.value.validate()
//
formLoading.value = true
try {
const data = formData.value as unknown as AlarmType
if (formType.value === 'create') {
await AlarmTypeApi.createAlarmType(data)
message.success(t('common.createSuccess'))
} else {
await AlarmTypeApi.updateAlarmType(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
//
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
nameColor: '#000',
color: undefined,
level: undefined,
sortOrder: undefined,
remark: undefined
}
formRef.value?.resetFields()
}
</script>

237
web/src/views/gas/alarmtype/index.vue

@ -0,0 +1,237 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="120px"
>
<el-form-item label="名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="警报方式/级别" prop="level">
<el-input
v-model="queryParams.level"
placeholder="请输入警报方式/级别"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['gas:alarm-type:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
<el-button
type="success"
plain
@click="handleExport"
:loading="exportLoading"
v-hasPermi="['gas:alarm-type:export']"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
<el-button
type="danger"
plain
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-hasPermi="['gas:alarm-type:delete']"
>
<Icon icon="ep:delete" class="mr-5px" /> 批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table
row-key="id"
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleRowCheckboxChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="名称" align="center" prop="name" />
<el-table-column label="图例" align="center"
><template #default="scope">
<div class="flex items-center">
<div
class="w-4px h-4px rounded-full"
:style="{ backgroundColor: scope.row.color, color: scope.row.nameColor || '#000' }"
>
{{ getDictLabel(DICT_TYPE.HAND_DETECTOR_ALARM_LEVEL, scope.row.level as number) }}
</div>
</div>
</template></el-table-column
>
<el-table-column label="警报方式/级别" align="center" prop="level" />
<el-table-column label="排序" align="center" prop="sortOrder" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center" min-width="120px">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['gas:alarm-type:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['gas:alarm-type:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<AlarmTypeForm
ref="formRef"
@success="getList"
:alarmLevels="getIntDictOptions(DICT_TYPE.HAND_DETECTOR_ALARM_LEVEL)"
/>
</template>
<script setup lang="ts">
import { isEmpty } from '@/utils/is'
import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import { AlarmTypeApi, AlarmType } from '@/api/gas/alarmtype'
import AlarmTypeForm from './AlarmTypeForm.vue'
import { DICT_TYPE, getIntDictOptions, getDictLabel } from '@/utils/dict'
/** GAS警报类型 列表 */
defineOptions({ name: 'AlarmType' })
const message = useMessage() //
const { t } = useI18n() //
const loading = ref(true) //
const list = ref<AlarmType[]>([]) //
const total = ref(0) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
level: undefined,
remark: undefined
})
const queryFormRef = ref() //
const exportLoading = ref(false) //
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await AlarmTypeApi.getAlarmTypePage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
//
await message.delConfirm()
//
await AlarmTypeApi.deleteAlarmType(id)
message.success(t('common.delSuccess'))
//
await getList()
} catch {}
}
/** 批量删除GAS警报类型 */
const handleDeleteBatch = async () => {
try {
//
await message.delConfirm()
await AlarmTypeApi.deleteAlarmTypeList(checkedIds.value)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (records: AlarmType[]) => {
checkedIds.value = records.map((item) => item.id)
}
/** 导出按钮操作 */
const handleExport = async () => {
try {
//
await message.exportConfirm()
//
exportLoading.value = true
const data = await AlarmTypeApi.exportAlarmType(queryParams)
download.excel(data, 'GAS警报类型.xls')
} catch {
} finally {
exportLoading.value = false
}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

190
web/src/views/gas/factory/FactoryForm.vue

@ -0,0 +1,190 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
v-loading="formLoading"
>
<el-form-item label="父节点ID" prop="parentId">
<el-input v-model="formData.parentId" placeholder="请输入父节点ID" />
</el-form-item>
<el-form-item label="层级(1:工厂;2:车间;3:班组)" prop="type">
<el-select v-model="formData.type" placeholder="请选择层级(1:工厂;2:车间;3:班组)">
<el-option label="请选择字典生成" value="" />
</el-select>
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="城市" prop="city">
<el-input v-model="formData.city" placeholder="请输入城市" />
</el-form-item>
<el-form-item label="总警报数" prop="alarmTotal">
<el-input v-model="formData.alarmTotal" placeholder="请输入总警报数" />
</el-form-item>
<el-form-item label="已处理警报数" prop="alarmDeal">
<el-input v-model="formData.alarmDeal" placeholder="请输入已处理警报数" />
</el-form-item>
<el-form-item label="区域图" prop="picUrl">
<el-input v-model="formData.picUrl" placeholder="请输入区域图" />
</el-form-item>
<el-form-item label="区域图缩放比例" prop="picScale">
<el-input v-model="formData.picScale" placeholder="请输入区域图缩放比例" />
</el-form-item>
<el-form-item label="在区域图X坐标值" prop="picX">
<el-input v-model="formData.picX" placeholder="请输入在区域图X坐标值" />
</el-form-item>
<el-form-item label="在区域图X坐标值" prop="picY">
<el-input v-model="formData.picY" placeholder="请输入在区域图X坐标值" />
</el-form-item>
<el-form-item label="经度" prop="longitude">
<el-input v-model="formData.longitude" placeholder="请输入经度" />
</el-form-item>
<el-form-item label="纬度" prop="latitude">
<el-input v-model="formData.latitude" placeholder="请输入纬度" />
</el-form-item>
<el-form-item label="区域西南坐标" prop="rectSouthWest">
<el-input v-model="formData.rectSouthWest" placeholder="请输入区域西南坐标" />
</el-form-item>
<el-form-item label="区域东北坐标" prop="rectNorthEast">
<el-input v-model="formData.rectNorthEast" placeholder="请输入区域东北坐标" />
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input v-model="formData.sortOrder" placeholder="请输入排序" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" placeholder="请输入备注" />
</el-form-item>
<el-form-item label="删除标志" prop="delFlag">
<el-input v-model="formData.delFlag" placeholder="请输入删除标志" />
</el-form-item>
<el-form-item label="创建者" prop="createBy">
<el-input v-model="formData.createBy" placeholder="请输入创建者" />
</el-form-item>
<el-form-item label="更新者" prop="updateBy">
<el-input v-model="formData.updateBy" placeholder="请输入更新者" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { FactoryApi, Factory } from '@/api/gas/factory'
/** GAS工厂 表单 */
defineOptions({ name: 'FactoryForm' })
const { t } = useI18n() //
const message = useMessage() //
const dialogVisible = ref(false) //
const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const formType = ref('') // create - update -
const formData = ref({
id: undefined,
parentId: undefined,
type: undefined,
name: undefined,
city: undefined,
alarmTotal: undefined,
alarmDeal: undefined,
picUrl: undefined,
picScale: undefined,
picX: undefined,
picY: undefined,
longitude: undefined,
latitude: undefined,
rectSouthWest: undefined,
rectNorthEast: undefined,
sortOrder: undefined,
remark: undefined,
delFlag: undefined,
createBy: undefined,
updateBy: undefined
})
const formRules = reactive({
parentId: [{ required: true, message: '父节点ID不能为空', trigger: 'blur' }],
name: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
alarmTotal: [{ required: true, message: '总警报数不能为空', trigger: 'blur' }],
alarmDeal: [{ required: true, message: '已处理警报数不能为空', trigger: 'blur' }],
sortOrder: [{ required: true, message: '排序不能为空', trigger: 'blur' }],
delFlag: [{ required: true, message: '删除标志不能为空', trigger: 'blur' }],
createBy: [{ required: true, message: '创建者不能为空', trigger: 'blur' }]
})
const formRef = ref() // Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
//
if (id) {
formLoading.value = true
try {
formData.value = await FactoryApi.getFactory(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // open
/** 提交表单 */
const emit = defineEmits(['success']) // success
const submitForm = async () => {
//
await formRef.value.validate()
//
formLoading.value = true
try {
const data = formData.value as unknown as Factory
if (formType.value === 'create') {
await FactoryApi.createFactory(data)
message.success(t('common.createSuccess'))
} else {
await FactoryApi.updateFactory(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
//
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
parentId: undefined,
type: undefined,
name: undefined,
city: undefined,
alarmTotal: undefined,
alarmDeal: undefined,
picUrl: undefined,
picScale: undefined,
picX: undefined,
picY: undefined,
longitude: undefined,
latitude: undefined,
rectSouthWest: undefined,
rectNorthEast: undefined,
sortOrder: undefined,
remark: undefined,
delFlag: undefined,
createBy: undefined,
updateBy: undefined
}
formRef.value?.resetFields()
}
</script>

400
web/src/views/gas/factory/index.vue

@ -0,0 +1,400 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="父节点ID" prop="parentId">
<el-input
v-model="queryParams.parentId"
placeholder="请输入父节点ID"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="层级(1:工厂;2:车间;3:班组)" prop="type">
<el-select
v-model="queryParams.type"
placeholder="请选择层级(1:工厂;2:车间;3:班组)"
clearable
class="!w-240px"
>
<el-option label="请选择字典生成" value="" />
</el-select>
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="城市" prop="city">
<el-input
v-model="queryParams.city"
placeholder="请输入城市"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="总警报数" prop="alarmTotal">
<el-input
v-model="queryParams.alarmTotal"
placeholder="请输入总警报数"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="已处理警报数" prop="alarmDeal">
<el-input
v-model="queryParams.alarmDeal"
placeholder="请输入已处理警报数"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="区域图" prop="picUrl">
<el-input
v-model="queryParams.picUrl"
placeholder="请输入区域图"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="区域图缩放比例" prop="picScale">
<el-input
v-model="queryParams.picScale"
placeholder="请输入区域图缩放比例"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="在区域图X坐标值" prop="picX">
<el-input
v-model="queryParams.picX"
placeholder="请输入在区域图X坐标值"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="在区域图X坐标值" prop="picY">
<el-input
v-model="queryParams.picY"
placeholder="请输入在区域图X坐标值"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="经度" prop="longitude">
<el-input
v-model="queryParams.longitude"
placeholder="请输入经度"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="纬度" prop="latitude">
<el-input
v-model="queryParams.latitude"
placeholder="请输入纬度"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="区域西南坐标" prop="rectSouthWest">
<el-input
v-model="queryParams.rectSouthWest"
placeholder="请输入区域西南坐标"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="区域东北坐标" prop="rectNorthEast">
<el-input
v-model="queryParams.rectNorthEast"
placeholder="请输入区域东北坐标"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input
v-model="queryParams.sortOrder"
placeholder="请输入排序"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="queryParams.remark"
placeholder="请输入备注"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="删除标志" prop="delFlag">
<el-input
v-model="queryParams.delFlag"
placeholder="请输入删除标志"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="创建者" prop="createBy">
<el-input
v-model="queryParams.createBy"
placeholder="请输入创建者"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['gas:factory:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
<el-button
type="success"
plain
@click="handleExport"
:loading="exportLoading"
v-hasPermi="['gas:factory:export']"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
<el-button
type="danger"
plain
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-hasPermi="['gas:factory:delete']"
>
<Icon icon="ep:delete" class="mr-5px" /> 批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table
row-key="id"
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleRowCheckboxChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="主键ID" align="center" prop="id" />
<el-table-column label="父节点ID" align="center" prop="parentId" />
<el-table-column label="层级(1:工厂;2:车间;3:班组)" align="center" prop="type" />
<el-table-column label="名称" align="center" prop="name" />
<el-table-column label="城市" align="center" prop="city" />
<el-table-column label="总警报数" align="center" prop="alarmTotal" />
<el-table-column label="已处理警报数" align="center" prop="alarmDeal" />
<el-table-column label="区域图" align="center" prop="picUrl" />
<el-table-column label="区域图缩放比例" align="center" prop="picScale" />
<el-table-column label="在区域图X坐标值" align="center" prop="picX" />
<el-table-column label="在区域图X坐标值" align="center" prop="picY" />
<el-table-column label="经度" align="center" prop="longitude" />
<el-table-column label="纬度" align="center" prop="latitude" />
<el-table-column label="区域西南坐标" align="center" prop="rectSouthWest" />
<el-table-column label="区域东北坐标" align="center" prop="rectNorthEast" />
<el-table-column label="排序" align="center" prop="sortOrder" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="删除标志" align="center" prop="delFlag" />
<el-table-column label="创建者" align="center" prop="createBy" />
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="更新者" align="center" prop="updateBy" />
<el-table-column label="操作" align="center" min-width="120px">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['gas:factory:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['gas:factory:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<FactoryForm ref="formRef" @success="getList" />
</template>
<script setup lang="ts">
import { isEmpty } from '@/utils/is'
import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import { FactoryApi, Factory } from '@/api/gas/factory'
import FactoryForm from './FactoryForm.vue'
/** GAS工厂 列表 */
defineOptions({ name: 'Factory' })
const message = useMessage() //
const { t } = useI18n() //
const loading = ref(true) //
const list = ref<Factory[]>([]) //
const total = ref(0) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
parentId: undefined,
type: undefined,
name: undefined,
city: undefined,
alarmTotal: undefined,
alarmDeal: undefined,
picUrl: undefined,
picScale: undefined,
picX: undefined,
picY: undefined,
longitude: undefined,
latitude: undefined,
rectSouthWest: undefined,
rectNorthEast: undefined,
sortOrder: undefined,
remark: undefined,
delFlag: undefined,
createBy: undefined,
createTime: [],
updateBy: undefined
})
const queryFormRef = ref() //
const exportLoading = ref(false) //
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await FactoryApi.getFactoryPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
//
await message.delConfirm()
//
await FactoryApi.deleteFactory(id)
message.success(t('common.delSuccess'))
//
await getList()
} catch {}
}
/** 批量删除GAS工厂 */
const handleDeleteBatch = async () => {
try {
//
await message.delConfirm()
await FactoryApi.deleteFactoryList(checkedIds.value)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (records: Factory[]) => {
checkedIds.value = records.map((item) => item.id)
}
/** 导出按钮操作 */
const handleExport = async () => {
try {
//
await message.exportConfirm()
//
exportLoading.value = true
const data = await FactoryApi.exportFactory(queryParams)
download.excel(data, 'GAS工厂.xls')
} catch {
} finally {
exportLoading.value = false
}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

132
web/src/views/gas/fence/FenceForm.vue

@ -0,0 +1,132 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
v-loading="formLoading"
>
<el-form-item label="围栏名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入围栏名称" />
</el-form-item>
<el-form-item label="围栏范围" prop="fenceRange">
<el-input v-model="formData.fenceRange" placeholder="请输入围栏范围" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio-button
v-for="dict in getIntDictOptions(DICT_TYPE.HAND_DETECTOR_FENCE_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="围栏类型" prop="type">
<el-radio-group v-model="formData.type">
<el-radio-button
v-for="dict in getIntDictOptions(DICT_TYPE.HAND_DETECTOR_FENCE_TYPE)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { FenceApi, Fence } from '@/api/gas/fence'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
/** GAS电子围栏 表单 */
defineOptions({ name: 'FenceForm' })
const { t } = useI18n() //
const message = useMessage() //
const dialogVisible = ref(false) //
const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const formType = ref('') // create - update -
const formData = ref({
id: undefined,
name: undefined,
fenceRange: undefined,
status: 1,
type: 1,
remark: undefined
})
const formRules = reactive({
name: [{ required: true, message: '围栏名称不能为空', trigger: 'blur' }],
fenceRange: [{ required: true, message: '围栏范围不能为空', trigger: 'blur' }],
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }],
type: [{ required: true, message: '围栏类型不能为空', trigger: 'blur' }]
})
const formRef = ref() // Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
//
if (id) {
formLoading.value = true
try {
formData.value = await FenceApi.getFence(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // open
/** 提交表单 */
const emit = defineEmits(['success']) // success
const submitForm = async () => {
//
await formRef.value.validate()
//
formLoading.value = true
try {
const data = formData.value as unknown as Fence
if (formType.value === 'create') {
await FenceApi.createFence(data)
message.success(t('common.createSuccess'))
} else {
await FenceApi.updateFence(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
//
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
fenceRange: undefined,
status: 1,
type: 1,
remark: undefined
}
formRef.value?.resetFields()
}
</script>

243
web/src/views/gas/fence/index.vue

@ -0,0 +1,243 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="围栏名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入围栏名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="围栏类型" prop="type">
<el-select
v-model="queryParams.type"
placeholder="请选择围栏类型"
clearable
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.HAND_DETECTOR_FENCE_TYPE)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['gas:fence:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
<el-button
type="success"
plain
@click="handleExport"
:loading="exportLoading"
v-hasPermi="['gas:fence:export']"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
<el-button
type="danger"
plain
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-hasPermi="['gas:fence:delete']"
>
<Icon icon="ep:delete" class="mr-5px" /> 批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table
row-key="id"
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleRowCheckboxChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="围栏名称" align="center" prop="name" />
<el-table-column label="状态" align="center" prop="status">
<template #default="scope">
<DictTag :type="DICT_TYPE.HAND_DETECTOR_FENCE_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column label="围栏类型" align="center" prop="type">
<template #default="scope">
<DictTag :type="DICT_TYPE.HAND_DETECTOR_FENCE_TYPE" :value="scope.row.type" />
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="操作" align="center" min-width="120px">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['gas:fence:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['gas:fence:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<FenceForm ref="formRef" @success="getList" />
</template>
<script setup lang="ts">
import { isEmpty } from '@/utils/is'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import download from '@/utils/download'
import { FenceApi, Fence } from '@/api/gas/fence'
import FenceForm from './FenceForm.vue'
/** GAS电子围栏 列表 */
defineOptions({ name: 'Fence' })
const message = useMessage() //
const { t } = useI18n() //
const loading = ref(true) //
const list = ref<Fence[]>([]) //
const total = ref(0) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
fenceRange: undefined,
status: undefined,
type: undefined,
remark: undefined,
createTime: []
})
const queryFormRef = ref() //
const exportLoading = ref(false) //
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await FenceApi.getFencePage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
//
await message.delConfirm()
//
await FenceApi.deleteFence(id)
message.success(t('common.delSuccess'))
//
await getList()
} catch {}
}
/** 批量删除GAS电子围栏 */
const handleDeleteBatch = async () => {
try {
//
await message.delConfirm()
await FenceApi.deleteFenceList(checkedIds.value)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (records: Fence[]) => {
checkedIds.value = records.map((item) => item.id)
}
/** 导出按钮操作 */
const handleExport = async () => {
try {
//
await message.exportConfirm()
//
exportLoading.value = true
const data = await FenceApi.exportFence(queryParams)
download.excel(data, 'GAS电子围栏.xls')
} catch {
} finally {
exportLoading.value = false
}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

194
web/src/views/gas/fencealarm/FenceAlarmForm.vue

@ -0,0 +1,194 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
v-loading="formLoading"
>
<el-form-item label="持有人" prop="detectorId">
<el-select v-model="formData.detectorId" placeholder="请选择持有人">
<el-option
v-for="item in props.handDetector"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="围栏" prop="fenceId">
<el-select v-model="formData.fenceId" placeholder="请选择围栏">
<el-option
v-for="item in props.fences"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="报警类型" prop="type">
<el-select v-model="formData.type" placeholder="请选择报警类型">
<el-option
v-for="item in props.alarmTypes"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="超出围栏米数" prop="distance">
<el-input-number v-model="formData.distance" />
</el-form-item>
<el-form-item label="最远超出米数" prop="maxDistance">
<el-input-number v-model="formData.maxDistance" />
</el-form-item>
<el-form-item label="开始时间" prop="tAlarmStart">
<el-date-picker
v-model="formData.tAlarmStart"
type="date"
value-format="x"
placeholder="选择开始时间"
/>
</el-form-item>
<el-form-item label="结束时间" prop="tAlarmEnd">
<el-date-picker
v-model="formData.tAlarmEnd"
type="date"
value-format="x"
placeholder="选择结束时间"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio
v-for="item in getIntDictOptions(DICT_TYPE.HAND_DETECTOR_HANDLE_STATUS)"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { FenceAlarmApi, FenceAlarm } from '@/api/gas/fencealarm'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { HandDetector } from '@/api/gas/handdetector'
import { Fence } from '@/api/gas/fence'
import { AlarmType } from '@/api/gas/alarmtype'
/** GAS手持探测器围栏报警 表单 */
defineOptions({ name: 'FenceAlarmForm' })
const { t } = useI18n() //
const message = useMessage() //
const props = defineProps({
handDetector: {
type: Array as PropType<HandDetector[]>,
required: true
},
fences: {
type: Array as PropType<Fence[]>,
required: true
},
alarmTypes: {
type: Array as PropType<AlarmType[]>,
required: true
}
})
const dialogVisible = ref(false) //
const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const formType = ref('') // create - update -
const formData = ref({
id: undefined,
detectorId: undefined,
fenceId: undefined,
type: undefined,
picX: undefined,
picY: undefined,
distance: undefined,
maxDistance: undefined,
tAlarmStart: undefined,
tAlarmEnd: undefined,
status: undefined,
remark: undefined
})
const formRules = reactive({
detectorId: [{ required: true, message: '持有人不能为空', trigger: 'blur' }],
fenceId: [{ required: true, message: '围栏不能为空', trigger: 'blur' }],
type: [{ required: true, message: '报警类型不能为空', trigger: 'blur' }]
})
const formRef = ref() // Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
//
if (id) {
formLoading.value = true
try {
formData.value = await FenceAlarmApi.getFenceAlarm(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // open
/** 提交表单 */
const emit = defineEmits(['success']) // success
const submitForm = async () => {
//
await formRef.value.validate()
//
formLoading.value = true
try {
const data = formData.value as unknown as FenceAlarm
if (formType.value === 'create') {
await FenceAlarmApi.createFenceAlarm(data)
message.success(t('common.createSuccess'))
} else {
await FenceAlarmApi.updateFenceAlarm(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
//
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
detectorId: undefined,
fenceId: undefined,
type: undefined,
picX: undefined,
picY: undefined,
distance: undefined,
maxDistance: undefined,
tAlarmStart: undefined,
tAlarmEnd: undefined,
status: undefined,
remark: undefined
}
formRef.value?.resetFields()
}
</script>

337
web/src/views/gas/fencealarm/index.vue

@ -0,0 +1,337 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="120px"
>
<el-form-item label="持有人" prop="detectorId">
<el-select
v-model="queryParams.detectorId"
placeholder="请选择持有人"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
>
<el-option
v-for="item in handDetectorStore.getHandDetectorList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="围栏" prop="fenceId">
<el-select
v-model="queryParams.fenceId"
placeholder="请选择围栏"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
>
<el-option
v-for="item in handDetectorStore.getFences"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="报警类型" prop="type">
<el-select
v-model="queryParams.type"
placeholder="请选择报警类型"
clearable
class="!w-240px"
>
<el-option
v-for="item in handDetectorStore.getAlarmTypes"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="开始时间" prop="tAlarmStart">
<el-date-picker
v-model="queryParams.tAlarmStart"
value-format="YYYY-MM-DD"
type="date"
placeholder="选择开始时间"
clearable
class="!w-240px"
/>
</el-form-item>
<el-form-item label="结束时间" prop="tAlarmEnd">
<el-date-picker
v-model="queryParams.tAlarmEnd"
value-format="YYYY-MM-DD"
type="date"
placeholder="选择结束时间"
clearable
class="!w-240px"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px">
<el-option
v-for="item in getIntDictOptions(DICT_TYPE.HAND_DETECTOR_HANDLE_STATUS)"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['gas:fence-alarm:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
<el-button
type="success"
plain
@click="handleExport"
:loading="exportLoading"
v-hasPermi="['gas:fence-alarm:export']"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
<el-button
type="danger"
plain
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-hasPermi="['gas:fence-alarm:delete']"
>
<Icon icon="ep:delete" class="mr-5px" /> 批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table
row-key="id"
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleRowCheckboxChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="持有人" align="center" prop="detectorId">
<template #default="scope">
{{
handDetectorStore.getHandDetectorList.find((item) => item.id === scope.row.detectorId)
?.name
}}
</template>
</el-table-column>
<el-table-column label="围栏" align="center" prop="fenceId">
<template #default="scope">
{{ handDetectorStore.getFences.find((item) => item.id === scope.row.fenceId)?.name }}
</template>
</el-table-column>
<el-table-column label="报警类型" align="center" prop="type">
<template #default="scope">
{{ handDetectorStore.getAlarmTypes.find((item) => item.id === scope.row.type)?.name }}
</template>
</el-table-column>
<el-table-column label="超出围栏米数" align="center" prop="distance" />
<el-table-column label="最远超出米数" align="center" prop="maxDistance" />
<el-table-column
label="开始时间"
align="center"
prop="tAlarmStart"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column
label="结束时间"
align="center"
prop="tAlarmEnd"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="状态" align="center" prop="status">
<template #default="scope">
<DictTag :type="DICT_TYPE.HAND_DETECTOR_HANDLE_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center" min-width="120px">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['gas:fence-alarm:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['gas:fence-alarm:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<FenceAlarmForm
ref="formRef"
@success="getList"
:handDetector="handDetectorStore.getHandDetectorList"
:fences="handDetectorStore.getFences"
:alarmTypes="handDetectorStore.getAlarmTypes"
/>
</template>
<script setup lang="ts">
import { isEmpty } from '@/utils/is'
import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import { FenceAlarmApi, FenceAlarm } from '@/api/gas/fencealarm'
import FenceAlarmForm from './FenceAlarmForm.vue'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { useHandDetectorStore } from '@/store/modules/handDetector'
/** GAS手持探测器围栏报警 列表 */
defineOptions({ name: 'FenceAlarm' })
const message = useMessage() //
const { t } = useI18n() //
const handDetectorStore = useHandDetectorStore() // store
const loading = ref(true) //
const list = ref<FenceAlarm[]>([]) //
const total = ref(0) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
detectorId: undefined,
fenceId: undefined,
type: undefined,
picX: undefined,
picY: undefined,
distance: undefined,
maxDistance: undefined,
tAlarmStart: undefined,
tAlarmEnd: undefined,
status: undefined,
remark: undefined,
createTime: []
})
const queryFormRef = ref() //
const exportLoading = ref(false) //
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await FenceAlarmApi.getFenceAlarmPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
//
await message.delConfirm()
//
await FenceAlarmApi.deleteFenceAlarm(id)
message.success(t('common.delSuccess'))
//
await getList()
} catch {}
}
/** 批量删除GAS手持探测器围栏报警 */
const handleDeleteBatch = async () => {
try {
//
await message.delConfirm()
await FenceAlarmApi.deleteFenceAlarmList(checkedIds.value)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (records: FenceAlarm[]) => {
checkedIds.value = records.map((item) => item.id)
}
/** 导出按钮操作 */
const handleExport = async () => {
try {
//
await message.exportConfirm()
//
exportLoading.value = true
const data = await FenceAlarmApi.exportFenceAlarm(queryParams)
download.excel(data, 'GAS手持探测器围栏报警.xls')
} catch {
} finally {
exportLoading.value = false
}
}
/** 初始化 **/
onMounted(() => {
getList()
handDetectorStore.getAllHandDetector()
handDetectorStore.getAllFences()
handDetectorStore.getAllAlarmTypes()
})
</script>

114
web/src/views/gas/gastype/TypeForm.vue

@ -0,0 +1,114 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
v-loading="formLoading"
>
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="化学式" prop="chemical">
<el-input v-model="formData.chemical" placeholder="请输入化学式" />
</el-form-item>
<el-form-item label="单位" prop="unit">
<el-input v-model="formData.unit" placeholder="请输入单位" />
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input v-model="formData.sortOrder" placeholder="请输入排序" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { TypeApi, Type } from '@/api/gas/gastype'
/** GAS气体 表单 */
defineOptions({ name: 'TypeForm' })
const { t } = useI18n() //
const message = useMessage() //
const dialogVisible = ref(false) //
const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const formType = ref('') // create - update -
const formData = ref({
id: undefined,
name: undefined,
chemical: undefined,
unit: undefined,
sortOrder: undefined,
remark: undefined
})
const formRules = reactive({
name: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
chemical: [{ required: true, message: '化学式不能为空', trigger: 'blur' }],
unit: [{ required: true, message: '单位不能为空', trigger: 'blur' }]
})
const formRef = ref() // Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
//
if (id) {
formLoading.value = true
try {
formData.value = await TypeApi.getType(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // open
/** 提交表单 */
const emit = defineEmits(['success']) // success
const submitForm = async () => {
//
await formRef.value.validate()
//
formLoading.value = true
try {
const data = formData.value as unknown as Type
if (formType.value === 'create') {
await TypeApi.createType(data)
message.success(t('common.createSuccess'))
} else {
await TypeApi.updateType(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
//
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
chemical: undefined,
unit: undefined,
sortOrder: undefined,
remark: undefined
}
formRef.value?.resetFields()
}
</script>

217
web/src/views/gas/gastype/index.vue

@ -0,0 +1,217 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['gas:type:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
<el-button
type="success"
plain
@click="handleExport"
:loading="exportLoading"
v-hasPermi="['gas:type:export']"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
<el-button
type="danger"
plain
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-hasPermi="['gas:type:delete']"
>
<Icon icon="ep:delete" class="mr-5px" /> 批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table
row-key="id"
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleRowCheckboxChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="名称" align="center" prop="name" />
<el-table-column label="化学式" align="center" prop="chemical" />
<el-table-column label="单位" align="center" prop="unit" />
<el-table-column label="排序" align="center" prop="sortOrder" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center" min-width="120px">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['gas:type:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['gas:type:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<TypeForm ref="formRef" @success="getList" />
</template>
<script setup lang="ts">
import { isEmpty } from '@/utils/is'
import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import { TypeApi, Type } from '@/api/gas/gastype'
import TypeForm from './TypeForm.vue'
/** GAS气体 列表 */
defineOptions({ name: 'GasType' })
const message = useMessage() //
const { t } = useI18n() //
const loading = ref(true) //
const list = ref<Type[]>([]) //
const total = ref(0) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
chemical: undefined,
unit: undefined,
sortOrder: undefined,
remark: undefined,
createTime: []
})
const queryFormRef = ref() //
const exportLoading = ref(false) //
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await TypeApi.getTypePage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
//
await message.delConfirm()
//
await TypeApi.deleteType(id)
message.success(t('common.delSuccess'))
//
await getList()
} catch {}
}
/** 批量删除GAS气体 */
const handleDeleteBatch = async () => {
try {
//
await message.delConfirm()
await TypeApi.deleteTypeList(checkedIds.value)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (records: Type[]) => {
checkedIds.value = records.map((item) => item.id)
}
/** 导出按钮操作 */
const handleExport = async () => {
try {
//
await message.exportConfirm()
//
exportLoading.value = true
const data = await TypeApi.exportType(queryParams)
download.excel(data, 'GAS气体.xls')
} catch {
} finally {
exportLoading.value = false
}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

249
web/src/views/gas/handalarm/HandAlarmForm.vue

@ -0,0 +1,249 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
v-loading="formLoading"
>
<el-form-item label="持有人" prop="detectorId">
<el-select
v-model="formData.detectorId"
placeholder="请选择持有人"
@change="handleDetectorIdChange"
>
<el-option
v-for="item in props.handDetector"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="设备编号" prop="sn">
<el-input v-model="formData.sn" placeholder="请输入设备编号" :disabled="true" />
</el-form-item>
<el-form-item label="报警类型" prop="alarmType">
<el-select
v-model="formData.alarmType"
@change="handleAlarmTypeChange"
placeholder="请选择报警类型"
>
<el-option
v-for="item in props.alarmTypes"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="警报方式/级别" prop="alarmLevel">
<el-select v-model="formData.alarmLevel" placeholder="请选择警报方式/级别" :disabled="true">
<el-option
v-for="item in getIntDictOptions(DICT_TYPE.HAND_DETECTOR_ALARM_LEVEL)"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="气体类型" prop="gasType">
<el-select
v-model="formData.gasType"
placeholder="请选择气体类型"
@change="handleGasTypeChange"
>
<el-option
v-for="item in props.gasTypes"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="单位" prop="unit">
<el-input v-model="formData.unit" placeholder="请输入单位" :disabled="true" />
</el-form-item>
<el-form-item label="首报值" prop="vAlarmFirst">
<el-input-number v-model="formData.vAlarmFirst" />
</el-form-item>
<el-form-item label="最值" prop="vAlarmMaximum">
<el-input-number v-model="formData.vAlarmMaximum" />
</el-form-item>
<el-form-item label="开始时间" prop="tAlarmStart">
<el-date-picker
v-model="formData.tAlarmStart"
type="date"
value-format="x"
placeholder="选择开始时间"
/>
</el-form-item>
<el-form-item label="结束时间" prop="tAlarmEnd">
<el-date-picker
v-model="formData.tAlarmEnd"
type="date"
value-format="x"
placeholder="选择结束时间"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio
v-for="item in getIntDictOptions(DICT_TYPE.HAND_DETECTOR_HANDLE_STATUS)"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { HandAlarmApi, HandAlarm } from '@/api/gas/handalarm'
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
import { HandDetector } from '@/api/gas/handdetector'
import { AlarmType } from '@/api/gas/alarmtype'
import { Type } from '@/api/gas/gastype'
/** GAS手持探测器警报 表单 */
defineOptions({ name: 'HandAlarmForm' })
const { t } = useI18n() //
const message = useMessage() //
const dialogVisible = ref(false) //
const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const formType = ref('') // create - update -
const props = defineProps({
handDetector: {
type: Array as PropType<HandDetector[]>,
required: true
},
alarmTypes: {
type: Array as PropType<AlarmType[]>,
required: true
},
gasTypes: {
type: Array as PropType<Type[]>,
required: true
}
})
const formData = ref({
id: undefined,
detectorId: undefined,
sn: '',
alarmType: undefined,
alarmLevel: 0,
gasType: '',
unit: '',
location: undefined,
picX: undefined,
picY: undefined,
vAlarmFirst: undefined,
vAlarmMaximum: undefined,
tAlarmStart: undefined,
tAlarmEnd: undefined,
status: 0,
remark: undefined
})
const formRules = reactive({
detectorId: [{ required: true, message: '持有人不能为空', trigger: 'change' }],
alarmType: [{ required: true, message: '报警类型不能为空', trigger: 'change' }],
gasType: [{ required: true, message: '气体类型不能为空', trigger: 'change' }],
unit: [{ required: true, message: '单位不能为空', trigger: 'blur' }],
remark: [{ required: true, message: '备注不能为空', trigger: 'blur' }]
})
const formRef = ref() // Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
//
if (id) {
formLoading.value = true
try {
formData.value = await HandAlarmApi.getHandAlarm(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // open
/** 提交表单 */
const emit = defineEmits(['success']) // success
const submitForm = async () => {
//
await formRef.value.validate()
//
formLoading.value = true
try {
const data = formData.value as unknown as HandAlarm
if (formType.value === 'create') {
await HandAlarmApi.createHandAlarm(data)
message.success(t('common.createSuccess'))
} else {
await HandAlarmApi.updateHandAlarm(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
//
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
detectorId: undefined,
sn: '',
alarmType: undefined,
alarmLevel: 0,
gasType: '',
unit: '',
location: undefined,
picX: undefined,
picY: undefined,
vAlarmFirst: undefined,
vAlarmMaximum: undefined,
tAlarmStart: undefined,
tAlarmEnd: undefined,
status: 0,
remark: undefined
}
formRef.value?.resetFields()
}
/** 气体类型改变 */
const handleGasTypeChange = (value: number) => {
formData.value.unit = props.gasTypes.find((item) => item.id === value)?.unit || ''
}
/** 手持表id改变 */
const handleDetectorIdChange = (value: number) => {
formData.value.sn = props.handDetector.find((item) => item.id === value)?.sn || ''
}
/** 报警类型改变 */
const handleAlarmTypeChange = (value: number) => {
formData.value.alarmLevel = props.alarmTypes.find((item) => item.id === value)?.level || 0
formData.value.gasType = props.gasTypes.find((item) => item.id === value)?.name || ''
formData.value.unit = props.gasTypes.find((item) => item.id === value)?.unit || ''
}
</script>

333
web/src/views/gas/handalarm/index.vue

@ -0,0 +1,333 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="120px"
>
<el-form-item label="持有人" prop="detectorId">
<el-select
v-model="queryParams.detectorId"
placeholder="请选择持有人"
clearable
filterable
@keyup.enter="handleQuery"
class="!w-240px"
>
<el-option
v-for="item in handDetectorStore.getHandDetectorList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="设备编号" prop="sn">
<el-input
v-model="queryParams.sn"
placeholder="请输入设备编号"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="报警类型" prop="alarmType">
<el-select
v-model="queryParams.alarmType"
placeholder="请选择报警类型"
clearable
class="!w-240px"
>
<el-option
v-for="item in handDetectorStore.getAlarmTypes"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="开始时间" prop="tAlarmStart">
<el-date-picker
v-model="queryParams.tAlarmStart"
value-format="YYYY-MM-DD"
type="date"
placeholder="选择开始时间"
clearable
class="!w-240px"
/>
</el-form-item>
<el-form-item label="结束时间" prop="tAlarmEnd">
<el-date-picker
v-model="queryParams.tAlarmEnd"
value-format="YYYY-MM-DD"
type="date"
placeholder="选择结束时间"
clearable
class="!w-240px"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px">
<el-option
v-for="item in getIntDictOptions(DICT_TYPE.HAND_DETECTOR_HANDLE_STATUS)"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['gas:hand-alarm:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
<el-button
type="success"
plain
@click="handleExport"
:loading="exportLoading"
v-hasPermi="['gas:hand-alarm:export']"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
<el-button
type="danger"
plain
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-hasPermi="['gas:hand-alarm:delete']"
>
<Icon icon="ep:delete" class="mr-5px" /> 批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table
row-key="id"
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleRowCheckboxChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="持有人" align="center" prop="detectorId">
<template #default="scope">
{{
handDetectorStore.getHandDetectorList.find((item) => item.id === scope.row.detectorId)
?.name
}}
</template>
</el-table-column>
<el-table-column label="设备编号" align="center" prop="sn" />
<el-table-column label="报警类型" align="center" prop="alarmType">
<template #default="scope">
{{
handDetectorStore.getAlarmTypes.find((item) => item.id === scope.row.alarmType)?.name
}}
</template>
</el-table-column>
<el-table-column label="气体类型" align="center" prop="gasType">
<template #default="scope">
{{ handDetectorStore.getGasTypes.find((item) => item.id === scope.row.gasType)?.name }}
</template>
</el-table-column>
<el-table-column label="首报值" align="center" prop="vAlarmFirst" />
<el-table-column label="最值" align="center" prop="vAlarmMaximum" />
<el-table-column
label="开始时间"
align="center"
prop="tAlarmStart"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column
label="结束时间"
align="center"
prop="tAlarmEnd"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="状态" align="center" prop="status" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center" min-width="120px">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['gas:hand-alarm:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['gas:hand-alarm:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<HandAlarmForm
ref="formRef"
@success="getList"
:handDetector="handDetectorStore.getHandDetectorList"
:gasTypes="handDetectorStore.getGasTypes"
:alarmTypes="handDetectorStore.getAlarmTypes"
/>
</template>
<script setup lang="ts">
import { isEmpty } from '@/utils/is'
import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import { HandAlarmApi, HandAlarm } from '@/api/gas/handalarm'
import HandAlarmForm from './HandAlarmForm.vue'
import { useHandDetectorStore } from '@/store/modules/handDetector'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
/** 手持探测器警报 列表 */
defineOptions({ name: 'HandAlarm' })
const message = useMessage() //
const { t } = useI18n() //
const handDetectorStore = useHandDetectorStore()
const loading = ref(true) //
const list = ref<HandAlarm[]>([]) //
const total = ref(0) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
detectorId: undefined,
sn: undefined,
alarmType: undefined,
alarmLevel: undefined,
gasType: undefined,
unit: undefined,
location: undefined,
picX: undefined,
picY: undefined,
vAlarmFirst: undefined,
vAlarmMaximum: undefined,
tAlarmStart: undefined,
tAlarmEnd: undefined,
status: undefined,
remark: undefined,
createTime: []
})
const queryFormRef = ref() //
const exportLoading = ref(false) //
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await HandAlarmApi.getHandAlarmPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
//
await message.delConfirm()
//
await HandAlarmApi.deleteHandAlarm(id)
message.success(t('common.delSuccess'))
//
await getList()
} catch {}
}
/** 批量删除GAS手持探测器警报 */
const handleDeleteBatch = async () => {
try {
//
await message.delConfirm()
await HandAlarmApi.deleteHandAlarmList(checkedIds.value)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (records: HandAlarm[]) => {
checkedIds.value = records.map((item) => item.id)
}
/** 导出按钮操作 */
const handleExport = async () => {
try {
//
await message.exportConfirm()
//
exportLoading.value = true
const data = await HandAlarmApi.exportHandAlarm(queryParams)
download.excel(data, '手持探测器警报.xls')
} catch {
} finally {
exportLoading.value = false
}
}
/** 初始化 **/
onMounted(() => {
getList()
handDetectorStore.getAllHandDetector()
handDetectorStore.getAllAlarmTypes()
handDetectorStore.getAllGasTypes()
})
</script>

217
web/src/views/gas/handdetector/HandDetectorForm.vue

@ -0,0 +1,217 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
v-loading="formLoading"
>
<el-form-item label="SN" prop="sn">
<el-input v-model="formData.sn" placeholder="请输入SN" />
</el-form-item>
<el-form-item label="持有人" prop="name">
<el-input v-model="formData.name" placeholder="请输入持有人" />
</el-form-item>
<el-form-item label="应用围栏" prop="fenceIdsArray">
<el-select v-model="formData.fenceIdsArray" placeholder="请选择应用围栏" multiple>
<el-option
v-for="fence in fences"
:key="fence.id"
:label="fence.name"
:value="fence.id"
/>
</el-select>
</el-form-item>
<el-form-item label="气体类型" prop="gasTypeId">
<el-select
v-model="formData.gasTypeId"
placeholder="请选择气体类型"
@change="handleGasTypeChange"
>
<el-option
v-for="gasType in gasTypes"
:key="gasType.id"
:label="gasType.name"
:value="gasType.id"
/>
</el-select>
</el-form-item>
<el-form-item label="气体化学式" prop="gasChemical">
<el-input v-model="formData.gasChemical" placeholder="请输入气体化学式" :disabled="true" />
</el-form-item>
<el-form-item label="单位" prop="unit">
<el-input v-model="formData.unit" placeholder="请输入单位" />
</el-form-item>
<el-form-item label="最小值" prop="min">
<el-input v-model="formData.min" placeholder="请输入最小值" />
</el-form-item>
<el-form-item label="最大值" prop="max">
<el-input v-model="formData.max" placeholder="请输入最大值" />
</el-form-item>
<el-form-item label="设备型号" prop="model">
<el-input v-model="formData.model" placeholder="请输入设备型号" />
</el-form-item>
<el-form-item label="生产厂家" prop="manufacturer">
<el-input v-model="formData.manufacturer" placeholder="请输入生产厂家" />
</el-form-item>
<el-form-item label="低电量报警" prop="batteryAlarmValue">
<el-input-number
:controls="false"
style="width: 100%"
v-model="formData.batteryAlarmValue"
placeholder="请输入低电量报警报警值"
/>
</el-form-item>
<el-form-item label="启用状态" prop="enableStatus">
<el-radio-group v-model="formData.enableStatus">
<el-radio-button
v-for="dict in getIntDictOptions(DICT_TYPE.HAND_DETECTOR_ENABLE_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="数值除数" prop="accuracy">
<el-input-number v-model="formData.accuracy" placeholder="请输入数值除数" />
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input v-model="formData.sortOrder" placeholder="请输入排序" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { HandDetectorApi, HandDetector } from '@/api/gas/handdetector'
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
import { Fence } from '@/api/gas/fence'
import { Type } from '@/api/gas/gastype'
/** GAS手持探测器 表单 */
defineOptions({ name: 'HandDetectorForm' })
const { t } = useI18n() //
const message = useMessage() //
const dialogVisible = ref(false) //
const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const formType = ref('') // create - update -
const props = defineProps({
fences: {
type: Array as PropType<Fence[]>,
required: true
},
gasTypes: {
type: Array as PropType<Type[]>,
required: true
}
})
const formData = ref({
id: undefined,
sn: undefined,
name: undefined,
fenceIds: '',
fenceIdsArray: [],
gasTypeId: undefined,
gasChemical: '',
min: 0,
max: undefined,
unit: '',
model: undefined,
manufacturer: undefined,
batteryAlarmValue: undefined,
enableStatus: 1,
accuracy: 1,
sortOrder: undefined,
remark: undefined
})
const formRules = reactive({
sn: [{ required: true, message: 'SN不能为空', trigger: 'blur' }],
name: [{ required: true, message: '持有人不能为空', trigger: 'blur' }],
gasTypeId: [{ required: true, message: '气体类型不能为空', trigger: 'blur' }],
enableStatus: [{ required: true, message: '启用状态不能为空', trigger: 'blur' }]
})
const formRef = ref() // Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
//
if (id) {
formLoading.value = true
try {
formData.value = await HandDetectorApi.getHandDetector(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // open
/** 提交表单 */
const emit = defineEmits(['success']) // success
const submitForm = async () => {
//
await formRef.value.validate()
//
formLoading.value = true
try {
const data = formData.value as unknown as HandDetector
data.fenceIds = data.fenceIdsArray?.join(',') || ''
if (formType.value === 'create') {
await HandDetectorApi.createHandDetector(data)
message.success(t('common.createSuccess'))
} else {
await HandDetectorApi.updateHandDetector(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
//
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
sn: undefined,
name: undefined,
fenceIds: '',
fenceIdsArray: [],
gasTypeId: undefined,
gasChemical: '',
min: 0,
max: undefined,
unit: '',
model: undefined,
manufacturer: undefined,
batteryAlarmValue: undefined,
enableStatus: 1,
accuracy: 1,
sortOrder: undefined,
remark: undefined
}
formRef.value?.resetFields()
}
const handleGasTypeChange = (value: number) => {
formData.value.gasChemical =
props.gasTypes.find((gasType) => gasType.id === value)?.chemical || ''
formData.value.unit = props.gasTypes.find((gasType) => gasType.id === value)?.unit || ''
}
</script>

249
web/src/views/gas/handdetector/index.vue

@ -0,0 +1,249 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="120px"
>
<el-form-item label="SN" prop="sn">
<el-input
v-model="queryParams.sn"
placeholder="请输入SN"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="持有人" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入持有人"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['gas:hand-detector:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
<el-button
type="success"
plain
@click="handleExport"
:loading="exportLoading"
v-hasPermi="['gas:hand-detector:export']"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
<el-button
type="danger"
plain
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-hasPermi="['gas:hand-detector:delete']"
>
<Icon icon="ep:delete" class="mr-5px" /> 批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table
row-key="id"
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleRowCheckboxChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="SN" align="center" prop="sn" />
<el-table-column label="持有人" align="center" prop="name" />
<el-table-column label="应用围栏" align="center" prop="fenceIds">
<template #default="scope">
{{
scope.row.fenceIdsArray &&
scope.row.fenceIdsArray.length > 0 &&
scope.row.fenceIdsArray
.map((item) => handDetectorStore.getFences.find((fence) => fence.id === item)?.name)
.join(',')
}}
</template>
</el-table-column>
<el-table-column label="气体类型" align="center" prop="gasTypeId">
<template #default="scope">
{{ handDetectorStore.getGasTypes.find((item) => item.id === scope.row.gasTypeId)?.name }}
</template>
</el-table-column>
<el-table-column label="启用状态" align="center" prop="enableStatus">
<template #default="scope">
<DictTag :type="DICT_TYPE.HAND_DETECTOR_ENABLE_STATUS" :value="scope.row.enableStatus" />
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="操作" align="center" min-width="120px">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['gas:hand-detector:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['gas:hand-detector:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<HandDetectorForm ref="formRef" @success="getList" :fences="fences" :gasTypes="gasTypes" />
</template>
<script setup lang="ts">
import { isEmpty } from '@/utils/is'
import download from '@/utils/download'
import { HandDetectorApi, HandDetector } from '@/api/gas/handdetector'
import HandDetectorForm from './HandDetectorForm.vue'
import { DICT_TYPE } from '@/utils/dict'
import { Fence } from '@/api/gas/fence'
import { Type } from '@/api/gas/gastype'
import { useHandDetectorStore } from '@/store/modules/handDetector'
/** GAS手持探测器 列表 */
defineOptions({ name: 'HandDetector' })
const handDetectorStore = useHandDetectorStore()
const message = useMessage() //
const { t } = useI18n() //
const loading = ref(true) //
const list = ref<HandDetector[]>([]) //
const total = ref(0) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
sn: undefined,
name: undefined,
createTime: []
})
const queryFormRef = ref() //
const exportLoading = ref(false) //
const fences = ref<Fence[]>([])
const gasTypes = ref<Type[]>([])
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await HandDetectorApi.getHandDetectorPage(queryParams)
data.list.forEach((item: HandDetector) => {
item.fenceIds && (item.fenceIdsArray = item.fenceIds.split(','))
})
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
//
await message.delConfirm()
//
await HandDetectorApi.deleteHandDetector(id)
message.success(t('common.delSuccess'))
//
await getList()
} catch {}
}
/** 批量删除GAS手持探测器 */
const handleDeleteBatch = async () => {
try {
//
await message.delConfirm()
await HandDetectorApi.deleteHandDetectorList(checkedIds.value)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (records: HandDetector[]) => {
checkedIds.value = records.map((item) => item.id)
}
/** 导出按钮操作 */
const handleExport = async () => {
try {
//
await message.exportConfirm()
//
exportLoading.value = true
const data = await HandDetectorApi.exportHandDetector(queryParams)
download.excel(data, '手持探测器.xls')
} catch {
} finally {
exportLoading.value = false
}
}
const getAllFences = async () => {
fences.value = await handDetectorStore.getAllFences()
}
const getAllGasTypes = async () => {
gasTypes.value = await handDetectorStore.getAllGasTypes()
}
/** 初始化 **/
onMounted(() => {
getList()
getAllFences()
getAllGasTypes()
})
</script>
Loading…
Cancel
Save