소스 검색

feat(设备管理): 添加设备解绑功能及告警计划区域选择

- 在设备列表页添加解绑设备功能,包括解绑确认弹窗和用户信息展示
- 新增BaseModal基础组件用于通用弹窗场景
- 为告警计划添加区域选择功能,支持拖拽和调整检测区域大小
- 修复设备统计抽屉中告警事件类型名称显示问题
- 添加设备绑定用户信息查询API和解绑API
liujia 1 개월 전
부모
커밋
f0d24de

+ 3 - 0
components.d.ts

@@ -18,6 +18,8 @@ declare module 'vue' {
     ACollapse: typeof import('ant-design-vue/es')['Collapse']
     ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
     AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
+    ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
+    ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
     ADrawer: typeof import('ant-design-vue/es')['Drawer']
     ADropdown: typeof import('ant-design-vue/es')['Dropdown']
     AEmpty: typeof import('ant-design-vue/es')['Empty']
@@ -55,6 +57,7 @@ declare module 'vue' {
     ATree: typeof import('ant-design-vue/es')['Tree']
     AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
     BaseCard: typeof import('./src/components/baseCard/index.vue')['default']
+    BaseModal: typeof import('./src/components/baseModal/index.vue')['default']
     BasePagination: typeof import('./src/components/basePagination/index.vue')['default']
     BaseWeather: typeof import('./src/components/baseWeather/index.vue')['default']
     ECharts: typeof import('./src/components/baseCard/components/e-charts/index.vue')['default']

+ 31 - 0
src/api/admin/index.ts

@@ -16,3 +16,34 @@ export const login = (params: TYPE.LoginParams): Promise<ResponseData<TYPE.Login
 export const logout = (): Promise<ResponseData<object>> => {
   return request.get('/manage/logout')
 }
+
+/**
+ * 获取设备绑定的用户信息
+ */
+export const getBindUserInfo = (params: {
+  userId: number
+}): Promise<
+  ResponseData<{
+    userId: number
+    openid: string // 用户openid,唯一标识
+    unionid: string // 用户unionid,唯一标识
+    phone: string // 用户手机号
+    nickname: string // 用户昵称
+    avatarUrl: string // 用户头像
+    gender: 0 | 1 | 2 // 用户性别,0:未知,1:男,2:女
+    country: string // 用户所在国家
+    province: string // 用户所在省份
+    city: string // 用户所在城市
+    language: string // 用户所用语言
+  }>
+> => {
+  return request.post('/admin/query/wxUser', params)
+}
+
+/**
+ * 解绑用户
+ * @param devId 设备id
+ */
+export const unbindUser = (params: { devId: number }): Promise<ResponseData<object>> => {
+  return request.post('/admin-deal/unbind', params)
+}

+ 61 - 0
src/components/baseModal/index.vue

@@ -0,0 +1,61 @@
+<template>
+  <div ref="mod">
+    <a-modal
+      :get-container="() => $refs.mod"
+      :open="props.open"
+      :title="props.title"
+      :mask-closable="false"
+      :width="props.width"
+      @cancel="cancel"
+      :footer="null"
+    >
+      <div v-if="!props.title" class="header">
+        <slot name="header"></slot>
+      </div>
+      <div class="body">
+        <slot></slot>
+      </div>
+      <div class="footer">
+        <slot name="footer"></slot>
+      </div>
+    </a-modal>
+  </div>
+</template>
+
+<script setup lang="ts">
+defineOptions({
+  name: 'BaseModal',
+})
+
+type Props = {
+  open: boolean
+  title: string
+  width?: string | number
+}
+const emit = defineEmits<{
+  (e: 'update:open', value: boolean): void
+}>()
+
+const props = withDefaults(defineProps<Props>(), {
+  open: false,
+  title: '',
+  width: 520,
+})
+
+// 关闭弹窗
+const cancel = () => {
+  emit('update:open', false)
+}
+</script>
+
+<style scoped lang="less">
+:deep(.ant-modal) {
+  .body {
+    min-height: 200px;
+    margin: 12px 0;
+  }
+  .footer {
+    text-align: right;
+  }
+}
+</style>

+ 286 - 6
src/views/device/detail/components/alarmPlanModal/index.vue

@@ -216,8 +216,56 @@
           </a-form-item-rest>
         </a-form-item>
 
-        <a-form-item label="检测区域" name="region">
-          检测区域选择组件 {{ formState.region }}
+        <a-form-item
+          v-if="[1, 2, 3, 9].includes(formState?.eventType as number)"
+          label="检测区域"
+          name="region"
+        >
+          框选区域 {{ formState.region }}
+          <a-form-item-rest>
+            <div class="viewer">
+              <div class="viewer-content">
+                <div
+                  class="mapBox blockArea"
+                  :style="{
+                    width: `${areaWidth}px`,
+                    height: `${areaHeight}px`,
+                    cursor: 'default',
+                  }"
+                >
+                  <!-- 已创建区块 -->
+                  <div
+                    v-for="(block, blockIndex) in blocks"
+                    :key="blockIndex"
+                    class="block-item"
+                    :style="{
+                      left: `${block.x}px`,
+                      top: `${block.y}px`,
+                      width: `${block.width}px`,
+                      height: `${block.height}px`,
+                      border: `2px solid #1890ff`,
+                      position: 'absolute',
+                      cursor: 'move',
+                      backgroundColor: 'rgba(24, 144, 255, 0.1)',
+                    }"
+                    @mousedown="startDrag(block, $event)"
+                    @mousemove="drag(block)"
+                    @mouseup="endDrag(block)"
+                  >
+                    <div
+                      class="resize-handle"
+                      :style="{
+                        backgroundColor: '#1890ff',
+                      }"
+                      @mousedown.stop="startResize(block, $event)"
+                    >
+                      <!-- {{ blockIndex + 1 }} -->
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </a-form-item-rest>
         </a-form-item>
 
         <a-form-item label="是否启用">
@@ -239,6 +287,7 @@
 import { ref, reactive, watch, computed } from 'vue'
 import { message, type FormInstance } from 'ant-design-vue'
 import * as alarmApi from '@/api/alarm'
+import { furnitureIconSizeMap } from '@/const/furniture'
 
 defineOptions({
   name: 'AlarmPlanModal',
@@ -283,6 +332,11 @@ type Props = {
   clientId: string // 设备ID
   alarmPlanId?: number | null // 告警计划ID 编辑时传入
   data?: AlarmPlan // 编辑数据
+  area?: {
+    width: number
+    height: number
+    ranges: number[]
+  }
 }
 const emit = defineEmits<{
   (e: 'update:open', value: boolean): void
@@ -301,6 +355,145 @@ const modelTitle = computed(() => {
   return props.alarmPlanId ? '编辑告警计划' : '新增告警计划'
 })
 
+// 检测区域宽度
+const areaWidth = computed(() => {
+  return Math.abs(props.area?.width || 0)
+})
+// 检测区域高度
+const areaHeight = computed(() => {
+  return Math.abs(props.area?.height || 0)
+})
+
+interface BlockItem {
+  // 本地用
+  x: number // 区块基于父元素的X偏移量,区块的左上角x坐标
+  y: number // 区块基于父元素的Y偏移量,区块的左上角y坐标
+  ox: number // 区块基于原点的X偏移量,区块的左上角x坐标
+  oy: number // 区块基于原点的Y偏移量,区块的左上角y坐标
+  width: number // 区块宽度
+  height: number // 区块高度
+}
+
+const blocks = ref<BlockItem[]>([])
+
+/**
+ * 获取坐标位置
+ * @param offsetLeft 元素基于父容器的X坐标
+ * @param offsetTop 元素基于父容器的Y坐标
+ */
+const getOriginPosition = ([offsetLeft, offsetTop]: number[] = [0, 0]) => {
+  const [xstart, xend, ystart, yend] = props.area!.ranges
+
+  // 容器宽高
+  const containerWidth = Math.abs(xstart) + Math.abs(xend)
+  const containerHeight = Math.abs(ystart) + Math.abs(yend)
+
+  // 原点在容器中的坐标
+  const originX = Math.abs(xstart)
+  const originY = Math.abs(yend)
+
+  // 元素基于父容器的偏移量
+  const offsetX = offsetLeft ?? 0
+  const offsetY = offsetTop ?? 0
+
+  // 元素基于原点的偏移量
+  const originOffsetX = offsetX - originX
+  const originOffsetY = originY - offsetY
+
+  // 雷达尺寸
+  const radarWidth = furnitureIconSizeMap['radar']?.width ?? 0
+  const radarHeight = furnitureIconSizeMap['radar']?.height ?? 0
+
+  // 雷达基于原点的偏移量
+  const radarX = Math.round(originX - radarWidth / 2)
+  const radarY = Math.round(originY - radarHeight / 2)
+
+  const data = {
+    width: containerWidth, // 容器宽度
+    height: containerHeight, // 容器高度
+    originX: Math.round(originX), // 原点X坐标
+    originY: Math.round(originY), // 原点Y坐标
+    offsetX: Math.round(offsetX), // 元素基于父容器的偏移量 X坐标
+    offsetY: Math.round(offsetY), // 元素基于父容器的偏移量 Y坐标
+    originOffsetX: Math.round(originOffsetX), // 元素基于原点的偏移量 X坐标
+    originOffsetY: Math.round(originOffsetY), // 元素基于原点的偏移量 Y坐标
+    radarX, // 雷达X坐标
+    radarY, // 雷达Y坐标
+    radarWidth, // 雷达宽度
+    radarHeight, // 雷达高度
+  }
+
+  return data
+}
+
+// 区块拖动
+const startDrag = (block: BlockItem, e: MouseEvent) => {
+  console.log('startDrag', block)
+  e.stopPropagation()
+  const container = document.querySelector('.blockArea') as HTMLElement
+  const rect = container.getBoundingClientRect()
+  const offsetX = e.clientX - rect.left - block.x
+  const offsetY = e.clientY - rect.top - block.y
+
+  const moveHandler = (e: MouseEvent) => {
+    const newX = e.clientX - rect.left - offsetX
+    const newY = e.clientY - rect.top - offsetY
+    const containerWidth = container.offsetWidth
+    const containerHeight = container.offsetHeight
+
+    block.x = Math.max(0, Math.min(newX, containerWidth - block.width))
+    block.y = Math.max(0, Math.min(newY, containerHeight - block.height))
+    block.ox = block.x - getOriginPosition().originX
+    block.oy = getOriginPosition().originY - block.y
+  }
+
+  const upHandler = () => {
+    document.removeEventListener('mousemove', moveHandler)
+    document.removeEventListener('mouseup', upHandler)
+  }
+
+  document.addEventListener('mousemove', moveHandler)
+  document.addEventListener('mouseup', upHandler)
+}
+
+const drag = (block: BlockItem) => {
+  formState.region = [block.ox, block.oy, block.width, block.height]
+}
+
+const endDrag = (block: BlockItem) => {
+  formState.region = [block.ox, block.oy, block.width, block.height]
+}
+
+// 获取容器边界
+const getContainerRect = () => {
+  const container = document.querySelector('.blockArea') as HTMLElement
+  return container?.getBoundingClientRect() || { left: 0, top: 0 }
+}
+
+const startResize = (block: BlockItem, e: MouseEvent) => {
+  const startX = e.clientX
+  const startY = e.clientY
+  const initialWidth = block.width
+  const initialHeight = block.height
+
+  const moveHandler = (e: MouseEvent) => {
+    const rect = getContainerRect()
+    const deltaX = e.clientX - startX
+    const deltaY = e.clientY - startY
+    // 限制最小尺寸和容器边界
+    block.width = Math.max(50, Math.min(initialWidth + deltaX, rect.width - block.x))
+    block.height = Math.max(50, Math.min(initialHeight + deltaY, rect.height - block.y))
+  }
+
+  const upHandler = () => {
+    document.removeEventListener('mousemove', moveHandler)
+    document.removeEventListener('mouseup', upHandler)
+  }
+
+  document.addEventListener('mousemove', moveHandler)
+  document.addEventListener('mouseup', upHandler)
+}
+
 const weekOptions = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
 const monthOptions = [
   1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
@@ -327,7 +520,7 @@ type FormState = {
 
 const formState = reactive<FormState>({
   planName: '',
-  region: [-200, 200, 400, 400],
+  region: [0, 0, 50, 50],
   eventType: null,
   thresholdTime: 300,
   mergeTime: 30,
@@ -342,6 +535,20 @@ const formState = reactive<FormState>({
   timeThreshold: 300,
 })
 
+const initBlocks = () => {
+  blocks.value = [
+    {
+      x: 0,
+      y: 0,
+      ox: formState.region[0],
+      oy: formState.region[1],
+      width: formState.region[2],
+      height: formState.region[3],
+    },
+  ]
+}
+initBlocks()
+
 const thresholdTimeFormat = ref<'s' | 'min' | 'hour' | 'day'>('s') // 触发阈值 额外选择器
 const timeThresholdFormat = ref<'s' | 'min' | 'hour' | 'day'>('s') // 异常消失时间阈值 额外选择器
 
@@ -382,7 +589,7 @@ const numToWeekMap: Record<number, string> = {
 watch(
   () => formState.effectTimeRanges,
   (val) => {
-    checkState.indeterminate = !!val.length && val.length < plainOptions.value.length //设置全选按钮的半选状态
+    checkState.indeterminate = !!val.length && val.length < plainOptions.value.length // 设置全选按钮的半选状态
     checkState.checkAll = val.length === plainOptions.value.length // 设置全选按钮的选中状态
   },
   {
@@ -481,6 +688,8 @@ watch(
       formState.effectType = echoFormState(val).effectType
       formState.effectTimeRanges = echoFormState(val).effectTimeRanges
       formState.effectTimeFrames = echoFormState(val).effectTimeFrames
+      formState.region = echoFormState(val).region
+      initBlocks()
     }
   },
   { immediate: true }
@@ -631,7 +840,9 @@ const submit = () => {
         mergeTime: Number(formState.mergeTime) || 30, // 归并时间
         eventVal: formState.eventType as number, // 事件类型 与 param 有联动关系
         param: JSON.stringify(paramData), // 事件参数 与 eventVal 有联动关系
-        region: JSON.stringify(formState.region), // 检测区域
+        region: [1, 2, 3, 9].includes(formState.eventType as number)
+          ? JSON.stringify(formState.region)
+          : [], // 检测区域
         enable: Number(formState.enable) as 0 | 1, // 是否启用 0否 1是
 
         // 生效方式
@@ -664,7 +875,7 @@ const submit = () => {
         message.warn('请选择生效方式的范围')
         return
       }
-      // if (formState.region.length !== 4) {
+      // if ([1, 2, 3, 9].includes(formState.eventType as number) && formState.region.length !== 4) {
       //   message.warn('请选择检测区域')
       //   return
       // }
@@ -738,4 +949,73 @@ const submit = () => {
     }
   }
 }
+
+.viewer {
+  padding: 10px;
+  min-width: 500px;
+  flex-shrink: 0;
+
+  &-header {
+    display: flex;
+    justify-content: space-between;
+    padding-bottom: 20px;
+
+    &-title {
+      font-size: 16px;
+      font-weight: 600;
+      line-height: 24px;
+    }
+    &-subtitle {
+      font-size: 14px;
+      color: #666;
+    }
+  }
+
+  &-content {
+    display: flex;
+    gap: 20px;
+  }
+}
+
+.mapBox {
+  background-color: #e0e0e0;
+  background-image:
+    linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px),
+    linear-gradient(to right, rgba(0, 0, 0, 0.1) 1px, transparent 1px);
+  background-size: 20px 20px;
+  position: relative;
+  flex-shrink: 0;
+
+  // 添加黑边框
+  &::before {
+    content: '';
+    position: absolute;
+    top: -5px;
+    left: -5px;
+    width: calc(100% + 10px);
+    height: calc(100% + 10px);
+    border: 5px solid rgba(0, 0, 0, 0.8);
+    box-sizing: border-box;
+    pointer-events: none;
+  }
+}
+
+.block-item {
+  background: rgba(24, 144, 255, 0.1);
+
+  .resize-handle {
+    position: absolute;
+    right: -4px;
+    bottom: -4px;
+    width: 15px;
+    height: 15px;
+    background: #1890ff;
+    cursor: nwse-resize;
+    font-size: 12px;
+    color: #fff;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+}
 </style>

+ 1 - 1
src/views/device/detail/components/deviceStatsDrawer/index.vue

@@ -269,7 +269,7 @@ const fetchAlarmList = async () => {
     const { rows, total } = res.data
     alarmList.value = rows as StatsAlarmQueryDataRow[]
     alarmList.value.forEach((item) => {
-      item.eventTypeName = alarmEventTypeName(String(item.eventType))
+      item.eventTypeName = alarmEventTypeName(item.eventType)
     })
     tableList.value = alarmList.value
     tableTotal.value = Number(total)

+ 10 - 0
src/views/device/detail/index.vue

@@ -293,6 +293,16 @@
         :client-id="clientId"
         :alarm-plan-id="alarmPlanId"
         :data="alarmPlanData"
+        :area="{
+          width: (detailState.width as number) ?? 0,
+          height: (detailState.length as number) ?? 0,
+          ranges: [
+            (detailState.xxStart as number) ?? 0,
+            (detailState.xxEnd as number) ?? 0,
+            (detailState.yyStart as number) ?? 0,
+            (detailState.yyEnd as number) ?? 0,
+          ],
+        }"
         @success="fetchAlarmPlanList"
       ></alarmPlanModal>
     </div>

+ 124 - 0
src/views/device/list/index.vue

@@ -80,6 +80,7 @@
             <a-button type="link" @click="detailHandler(record.devId, record.clientId)"
               >查看详情</a-button
             >
+            <a-button type="link" @click="unbindDeviceHandler(record)">解绑设备</a-button>
           </template>
         </template>
       </a-table>
@@ -106,6 +107,37 @@
       title="批量上传设备"
       @success="searchHandler"
     ></upload-device-modal>
+
+    <baseModal v-model:open="unbindOpen" title="解绑设备">
+      <a-descriptions title="" bordered :column="1" size="middle">
+        <a-descriptions-item label="设备ID">{{ unbindDeviceData.devId }}</a-descriptions-item>
+        <a-descriptions-item label="设备名称">{{ unbindDeviceData.devName }}</a-descriptions-item>
+        <a-descriptions-item label="设备状态">
+          <a-tag v-if="unbindDeviceData.online === -1" :bordered="false" color="gray">未激活</a-tag>
+          <a-tag v-if="unbindDeviceData.online === 0" :bordered="false" color="red">离线</a-tag>
+          <a-tag v-if="unbindDeviceData.online === 1" :bordered="false" color="green">在线</a-tag>
+        </a-descriptions-item>
+        <a-descriptions-item label="绑定用户ID">{{ unbindDeviceData.userId }}</a-descriptions-item>
+        <a-descriptions-item label="用户手机号">
+          {{ unbindDeviceData.userPhone }}
+        </a-descriptions-item>
+        <a-descriptions-item label="绑定时间">{{ unbindDeviceData.bindTime }}</a-descriptions-item>
+      </a-descriptions>
+
+      <template #footer>
+        <a-space class="unbindDevice-btn">
+          <a-button @click="unbindOpen = false">取消</a-button>
+          <a-popconfirm
+            title="确认解绑该设备吗?"
+            ok-text="确认"
+            cancel-text="取消"
+            @confirm="confirmUnbindDevice(unbindDeviceData.devId)"
+          >
+            <a-button type="primary">解绑</a-button>
+          </a-popconfirm>
+        </a-space>
+      </template>
+    </baseModal>
   </div>
 </template>
 
@@ -120,6 +152,8 @@ import { useSearch } from '@/hooks/useSearch'
 import { useRouter } from 'vue-router'
 import * as tenantAPI from '@/api/tenant'
 import type { TenantItem } from '@/api/tenant/types'
+import * as adminAPI from '@/api/admin'
+import * as deviceApi from '@/api/device'
 
 const router = useRouter()
 
@@ -266,6 +300,88 @@ const detailHandler = (devId: string, clientId: string) => {
   })
 }
 
+const unbindOpen = ref(false)
+const unbindModalLoading = ref(false)
+const unbindDeviceData = ref<{
+  devId: string
+  clientId: string
+  devName: string
+  online: number
+  userId: number
+  userPhone: string
+  bindTime: string
+}>({
+  devId: '',
+  clientId: '',
+  devName: '',
+  online: 0,
+  userId: 0,
+  userPhone: '',
+  bindTime: '',
+})
+// 解绑设备
+const unbindDeviceHandler = async (device: Device) => {
+  console.log('解绑设备')
+  unbindDeviceData.value = {
+    devId: device.devId + '',
+    clientId: device.clientId,
+    devName: device.devName,
+    online: device.online,
+    userId: device.userId,
+    userPhone: '',
+    bindTime: '',
+  }
+  unbindOpen.value = true
+  await fetchDeviceBindUser(device.userId)
+  await fetchDeviceDetail(device.devId)
+}
+
+// 确认解绑设备
+const confirmUnbindDevice = async (devId: string) => {
+  console.log('确认解绑设备')
+  try {
+    await adminAPI.unbindUser({ devId: Number(devId) })
+    unbindOpen.value = false
+  } catch (err) {
+    console.log('解绑设备失败', err)
+  }
+}
+
+// 获取设备绑定用户信息
+const fetchDeviceBindUser = async (userId: number) => {
+  console.log('获取设备绑定用户信息', userId)
+  if (!userId) return
+  try {
+    unbindModalLoading.value = true
+    const res = await adminAPI.getBindUserInfo({
+      userId,
+    })
+    console.log('获取设备绑定用户信息成功', res)
+    unbindModalLoading.value = false
+    const data = res.data
+    unbindDeviceData.value.userPhone = data.phone
+  } catch (err) {
+    console.log('获取设备绑定用户信息失败', err)
+    unbindModalLoading.value = false
+  }
+}
+
+// 从设备详情获取设备的激活时间
+const fetchDeviceDetail = async (devId: number) => {
+  console.log('fetchDeviceDetail', devId)
+  if (!devId) return
+  try {
+    const res = await deviceApi.getDeviceDetailByDevId({
+      devId: String(devId),
+    })
+    console.log('✅获取到设备详情', res)
+    const data = res.data
+    unbindDeviceData.value.bindTime = data.activeTime
+  } catch (error) {
+    console.error('❌获取设备详情失败', error)
+  }
+}
+
 const addDeviceOpen = ref(false)
 // 添加设备
 const addDeviceHandler = () => {
@@ -316,4 +432,12 @@ const uploadDeviceHandler = () => {
     }
   }
 }
+
+.unbindDevice-btn {
+  margin-top: 12px;
+}
+
+:deep(.ant-descriptions-item-label) {
+  width: 150px;
+}
 </style>