index.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. <template>
  2. <a-spin :spinning="spinning">
  3. <div class="deviceDetail">
  4. <div class="radarMap">
  5. <info-card title="房间监测">
  6. <template #extra>
  7. <a-button type="primary" size="small" @click="roomConfigHandler('area')">
  8. 区域配置
  9. </a-button>
  10. </template>
  11. <div
  12. class="radarBox"
  13. :style="{
  14. width: `${detailState?.length}px`,
  15. height: `${detailState?.width}px`,
  16. }"
  17. >
  18. <template v-if="targets && Object.keys(targets).length > 0">
  19. <template v-for="t in targets" :key="t.id">
  20. <div
  21. class="target-dot"
  22. :style="{
  23. position: 'absolute',
  24. width: '18px',
  25. height: '18px',
  26. background: t.id === 0 ? 'red' : t.id === 1 ? 'blue' : 'green',
  27. borderRadius: '50%',
  28. transform: `translate3d(${t.displayX + 200}px, ${-t.displayY + 200}px, 0) translate(-50%, -50%)`,
  29. zIndex: 10,
  30. transition: 'transform 1s linear',
  31. willChange: 'transform',
  32. }"
  33. >
  34. <span
  35. style="
  36. color: #fff;
  37. font-size: 12px;
  38. font-weight: 600;
  39. position: absolute;
  40. left: 50%;
  41. top: 50%;
  42. transform: translate(-50%, -50%);
  43. pointer-events: none;
  44. "
  45. >
  46. {{ t.id + 1 }}
  47. </span>
  48. </div>
  49. </template>
  50. </template>
  51. <div>
  52. <furniture-icon
  53. v-for="(item, index) in furnitureItems"
  54. :key="index"
  55. :icon="item.type"
  56. :width="item.width"
  57. :height="item.length"
  58. :style="{
  59. left: `${item.left}px`,
  60. top: `${item.top}px`,
  61. position: 'absolute',
  62. rotate: `${item.rotate}deg`,
  63. cursor: 'default',
  64. pointerEvents: 'none',
  65. }"
  66. :draggable="false"
  67. />
  68. </div>
  69. </div>
  70. </info-card>
  71. </div>
  72. <div class="infos">
  73. <info-card title="基本信息">
  74. <template #extra>
  75. <a-button type="primary" size="small" @click="roomConfigHandler('base')">
  76. 设备配置
  77. </a-button>
  78. </template>
  79. <info-item label="设备ID">{{ detailState.clientId }}</info-item>
  80. <info-item label="设备名称">{{ detailState.devName }}</info-item>
  81. <info-item label="设备类型">{{ detailState.devType }}</info-item>
  82. <info-item label="固件版本号">{{ detailState.hardware }}</info-item>
  83. <info-item label="激活日期">{{ detailState.activeTime }}</info-item>
  84. <info-item label="在离线状态">
  85. <a-tag
  86. v-if="detailState.online === 0"
  87. :bordered="false"
  88. :color="deviceOnlineStateMap[detailState.online].color"
  89. >{{ deviceOnlineStateMap[detailState.online].name }}</a-tag
  90. >
  91. <a-tag
  92. v-if="detailState.online === 1"
  93. :bordered="false"
  94. :color="deviceOnlineStateMap[detailState.online].color"
  95. >{{ deviceOnlineStateMap[detailState.online].name }}</a-tag
  96. >
  97. </info-item>
  98. <info-item label="归属租户">{{ detailState.tenantName }}</info-item>
  99. <info-item label="统计信息">
  100. <a-button type="link" size="small" @click="viewDeviceHistoryInfo"> 点击查看 </a-button>
  101. </info-item>
  102. </info-card>
  103. <info-card title="安装参数">
  104. <info-item label="安装高度">
  105. <template v-if="detailState.height"> {{ detailState.height }} cm</template>
  106. </info-item>
  107. <info-item label="检测区域">
  108. <template v-if="detailState.length || detailState.width">
  109. {{ detailState.length || 0 }} x {{ detailState.width || 0 }} cm
  110. </template>
  111. </info-item>
  112. <info-item label="安装位置">
  113. {{
  114. deviceInstallPositionNameMap[
  115. detailState.installPosition as keyof typeof deviceInstallPositionNameMap
  116. ]
  117. }}
  118. </info-item>
  119. </info-card>
  120. </div>
  121. <deviceConfigDrawer
  122. v-model:open="configDrawerOpen"
  123. :data="{
  124. devId: (detailState.devId as string) || '',
  125. clientId: (detailState.clientId as string) || '',
  126. length: (detailState.length as number) || 0,
  127. width: (detailState.width as number) || 0,
  128. }"
  129. :mode="configDrawerMode"
  130. :room-id="deviceRoomId"
  131. :furniture-items="furnitureItems"
  132. :sub-region-items="subRegionItems"
  133. @success="saveConfigSuccess"
  134. ></deviceConfigDrawer>
  135. <deviceStatsDrawer
  136. v-model:open="statsDrawerOpen"
  137. :dev-id="`${detailState.devId as string}`"
  138. :title="`${detailState.devName} 统计信息`"
  139. ></deviceStatsDrawer>
  140. </div>
  141. </a-spin>
  142. </template>
  143. <script setup lang="ts">
  144. import infoCard from './components/infoCard/index.vue'
  145. import infoItem from './components/infoItem/index.vue'
  146. import { ref, reactive, onMounted, onUnmounted } from 'vue'
  147. import { useRoute } from 'vue-router'
  148. import { message } from 'ant-design-vue'
  149. import * as roomApi from '@/api/room'
  150. import type { Furniture } from '@/api/room/types'
  151. import mqtt, { MqttClient } from 'mqtt'
  152. import * as deviceApi from '@/api/device'
  153. import type { DeviceDetailData } from '@/api/device/types'
  154. import { deviceOnlineStateMap, deviceInstallPositionNameMap } from '@/const/device'
  155. import deviceConfigDrawer from './components/deviceConfig/index.vue'
  156. import deviceStatsDrawer from './components/deviceStatsDrawer/index.vue'
  157. defineOptions({
  158. name: 'DeviceDetail',
  159. })
  160. const route = useRoute()
  161. const devId = ref<string>((route.query.devId as string) || '') // 设备id
  162. const clientId = ref<string>((route.query.clientId as string) || '') // 设备id
  163. interface BlockItem {
  164. startXx: number // 屏蔽子区域X开始
  165. stopXx: number // 屏蔽子区域X结束
  166. startYy: number // 屏蔽子区域Y开始
  167. stopYy: number // 屏蔽子区域Y结束
  168. startZz: number // 屏蔽子区域Z开始
  169. stopZz: number // 屏蔽子区域Z结束
  170. isLowSnr: number // 默认0
  171. isDoor: number // 是否是门 0-否,1-是 默认0
  172. presenceEnterDuration: number // 人员进入时间 默认3
  173. presenceExitDuration: number // 人员离开时间 默认3
  174. trackPresence: number // 是否开启区域跟踪存在 0-否,1-是
  175. excludeFalling: number // 是否屏蔽区域跌倒检测 0-否,1-是
  176. }
  177. const deviceRoomId = ref<string>('')
  178. const furnitureItems = ref<Furniture[]>([])
  179. const subRegionItems = ref<BlockItem[]>([])
  180. /**
  181. * 获取房间布局
  182. */
  183. const fetchRoomLayout = async () => {
  184. console.log('fetchRoomLayout', devId.value)
  185. if (!devId.value) {
  186. message.error('设备ID不能为空')
  187. return
  188. }
  189. try {
  190. const res = await roomApi.queryRoomInfo({
  191. devId: devId.value,
  192. })
  193. console.log('✅获取到房间布局信息', res)
  194. if (!res) return
  195. const { furnitures, roomId, subRegions } = res.data
  196. if (furnitures) {
  197. furnitureItems.value = furnitures!.map((item) => {
  198. return {
  199. ...item,
  200. width: item.width || 45,
  201. length: item.length || 45,
  202. top: item.top || 0,
  203. left: item.left || 0,
  204. rotate: item.rotate || 0,
  205. x: item.x || 0,
  206. y: item.y || 0,
  207. }
  208. })
  209. }
  210. deviceRoomId.value = roomId || ''
  211. subRegionItems.value = subRegions || []
  212. } catch (error) {
  213. console.error('❌获取房间布局信息失败', error)
  214. }
  215. }
  216. fetchRoomLayout()
  217. const detailState = ref<DeviceDetailData>({
  218. devId: '',
  219. clientId: '',
  220. userId: '',
  221. devName: '',
  222. devType: '',
  223. online: 0,
  224. devWarn: 0,
  225. software: '',
  226. hardware: '',
  227. wifiName: '',
  228. wifiPassword: '',
  229. ip: '',
  230. mountPlain: 'Wall',
  231. installPosition: 'Toilet',
  232. xxStart: 0,
  233. xxEnd: 0,
  234. yyStart: 0,
  235. yyEnd: 0,
  236. zzStart: 0,
  237. zzEnd: 0,
  238. height: 0,
  239. length: 0,
  240. width: 0,
  241. targetPoints: [],
  242. signalTime: 0,
  243. northAngle: 0,
  244. activeTime: '',
  245. statusLight: 0,
  246. createId: '',
  247. updateId: '',
  248. createTime: '',
  249. updateTime: '',
  250. tenantName: '',
  251. tenantId: '',
  252. fallingConfirm: null,
  253. })
  254. const spinning = ref(false)
  255. // 获取设备详情
  256. const fetchDeviceDetail = async () => {
  257. console.log('fetchDeviceDetail', devId.value)
  258. if (!devId.value) {
  259. message.error('设备ID不能为空')
  260. return
  261. }
  262. try {
  263. spinning.value = true
  264. const res = await deviceApi.getDeviceDetailByDevId({
  265. devId: devId.value,
  266. })
  267. console.log('✅获取到设备详情', res)
  268. detailState.value = res.data
  269. spinning.value = false
  270. } catch (error) {
  271. console.error('❌获取设备详情失败', error)
  272. spinning.value = false
  273. }
  274. }
  275. fetchDeviceDetail()
  276. const saveConfigSuccess = () => {
  277. setTimeout(() => {
  278. fetchDeviceDetail()
  279. fetchRoomLayout()
  280. }, 1000)
  281. }
  282. const configDrawerOpen = ref(false)
  283. const configDrawerMode = ref<'base' | 'area'>('base')
  284. const roomConfigHandler = (type: 'base' | 'area') => {
  285. configDrawerMode.value = type
  286. configDrawerOpen.value = true
  287. }
  288. const statsDrawerOpen = ref(false)
  289. // 查看设备历史信息
  290. const viewDeviceHistoryInfo = () => {
  291. console.log('viewDeviceHistoryInfo')
  292. statsDrawerOpen.value = true
  293. }
  294. interface TargetInfo {
  295. x: number
  296. y: number
  297. z: number
  298. id: number
  299. displayX: number
  300. displayY: number
  301. lastX: number
  302. lastY: number
  303. }
  304. const targets = reactive<Record<number, TargetInfo>>({}) // 以id为key
  305. const THRESHOLD = 2 // 去抖阈值
  306. let mqttClient: MqttClient | null = null
  307. let mqttTimeout: number | null = null
  308. const MQTT_TIMEOUT_MS = 10000 // 10秒
  309. const resetMqttTimeout = () => {
  310. if (mqttTimeout) clearTimeout(mqttTimeout)
  311. mqttTimeout = window.setTimeout(() => {
  312. Object.keys(targets).forEach((key) => delete targets[Number(key)])
  313. console.log('MQTT超时未收到新消息,隐藏所有红点')
  314. }, MQTT_TIMEOUT_MS)
  315. }
  316. onMounted(() => {
  317. console.log('onMounted', mqttClient)
  318. const mqttConfig = {
  319. host: import.meta.env.VITE_MQTT_HOST,
  320. username: import.meta.env.VITE_MQTT_USERNAME,
  321. password: import.meta.env.VITE_MQTT_PASSWORD,
  322. clientId: `mqtt_client_${Math.random().toString(16).slice(2, 8)}`,
  323. }
  324. mqttClient = mqtt.connect(mqttConfig.host, {
  325. clientId: mqttConfig.clientId,
  326. username: mqttConfig.username,
  327. password: mqttConfig.password,
  328. })
  329. console.log('mqttClient connect ready', mqttClient)
  330. mqttClient.on('connect', () => {
  331. console.log('MQTT已连接')
  332. // 订阅所有设备的主题
  333. mqttClient?.subscribe(`/mps/${clientId.value}/realtime_pos`, (err) => {
  334. if (err) {
  335. console.error('MQTT订阅失败', err)
  336. } else {
  337. console.log(`已订阅主题 /mps/${clientId.value}/realtime_pos`)
  338. }
  339. })
  340. })
  341. mqttClient.on('error', (err) => {
  342. console.error('MQTT连接错误', err)
  343. })
  344. mqttClient.on('message', (topic: string, message: Uint8Array) => {
  345. resetMqttTimeout()
  346. const match = topic.match(/^\/mps\/(.+)\/realtime_pos$/)
  347. if (!match) return
  348. const msgDevId = match[1]
  349. if (msgDevId !== clientId.value) return // 只处理当前设备
  350. try {
  351. const data = JSON.parse(message.toString())
  352. const arr = data.targetPoints
  353. // console.log('收到MQTT消息', data.dev_id, data.targetPoints)
  354. if (Array.isArray(arr) && arr.length > 0 && Array.isArray(arr[0])) {
  355. // 记录本次出现的所有id
  356. const currentIds = new Set<number>()
  357. arr.forEach((item: number[]) => {
  358. if (item.length < 4) return
  359. const [x, y, z, id] = item
  360. currentIds.add(id)
  361. if (!(id in targets)) {
  362. targets[id] = { x, y, z, id, displayX: x, displayY: y, lastX: x, lastY: y }
  363. }
  364. // 去抖动
  365. const dx = x - targets[id].lastX
  366. const dy = y - targets[id].lastY
  367. if (Math.sqrt(dx * dx + dy * dy) > THRESHOLD) {
  368. targets[id].x = x
  369. targets[id].y = y
  370. targets[id].z = z
  371. targets[id].lastX = x
  372. targets[id].lastY = y
  373. targets[id].displayX = x
  374. targets[id].displayY = y
  375. console.log(`更新目标点: id=${id}, x=${x}, y=${y}`, targets[id])
  376. } else {
  377. // 距离太小,忽略本次更新
  378. // console.log(`忽略微小抖动 id=${id}`)
  379. }
  380. })
  381. // 删除本次未出现的id
  382. Object.keys(targets).forEach((key) => {
  383. const id = Number(key)
  384. if (!currentIds.has(id)) {
  385. delete targets[id]
  386. // console.log(`ID=${id} 消失,隐藏对应红点`)
  387. }
  388. })
  389. } else {
  390. // 没有目标时,隐藏所有红点
  391. Object.keys(targets).forEach((key) => delete targets[Number(key)])
  392. // console.log('tracker_targets为空,隐藏所有红点')
  393. }
  394. } catch (e) {
  395. console.error('MQTT消息解析失败', e)
  396. }
  397. })
  398. })
  399. onUnmounted(() => {
  400. if (mqttClient) mqttClient.end()
  401. if (mqttTimeout) clearTimeout(mqttTimeout)
  402. })
  403. </script>
  404. <style scoped lang="less">
  405. .deviceDetail {
  406. background-color: #fff;
  407. display: flex;
  408. justify-content: space-between;
  409. min-height: 500px;
  410. padding: 20px;
  411. .radarMap {
  412. flex-shrink: 0;
  413. margin-right: 16px;
  414. min-width: 400px;
  415. min-height: 400px;
  416. background-color: #f5f5f5;
  417. border-radius: 10px;
  418. :deep(.info) {
  419. &-content {
  420. align-items: center !important;
  421. }
  422. }
  423. .radarBox {
  424. position: relative;
  425. background-image:
  426. linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px),
  427. linear-gradient(to right, rgba(0, 0, 0, 0.1) 1px, transparent 1px);
  428. background-size: 20px 20px;
  429. border: 1px solid rgba(0, 0, 0, 0.8);
  430. overflow: hidden;
  431. .furniture-item {
  432. position: absolute;
  433. user-select: none;
  434. cursor: move;
  435. width: 30px;
  436. height: 30px;
  437. }
  438. }
  439. }
  440. .infos {
  441. flex-grow: 1;
  442. display: grid;
  443. grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  444. gap: 16px;
  445. }
  446. }
  447. </style>