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