Parcourir la source

Merge branch 'dev' into prod

liujia il y a 1 mois
Parent
commit
412324d576

+ 23 - 0
CHANGELOG.md

@@ -1,4 +1,27 @@
 
+## v0.5.4 (2025-09-03)
+- fix(utils): 解决打包时不能识别@/const问题,固定雷达尺寸为常量 (8d7eb78)
+
+## v0.5.3 (2025-09-03)
+- refactor(deviceStatsDrawer): 优化统计表格列配置和数据显示逻辑 (4f5e9f0)
+- fix(设备详情): 修复检测区域显示和交互问题 (09f1280)
+- refactor(device): 提取坐标计算逻辑到工具函数并优化家具处理 (654cd3a)
+
+## v0.5.2 (2025-09-02)
+- feat(设备管理): 添加设备解绑功能及告警计划区域选择 (f0d24de)
+- refactor(device-stats): 合并滞留统计为告警统计并优化相关代码 (ffdf08a)
+- fix(设备区域配置): 修正雷达图标尺寸和坐标计算逻辑 (4060772)
+
+## v0.5.1 (2025-09-01)
+- feat(设备配置): 添加区域范围配置和时间单位选择功能 (d5da336)
+- refactor(components): 移除未使用的组件类型声明 (a098568)
+
+## v0.5.0 (2025-08-29)
+- feat(设备详情): 新增告警计划管理功能 (81e759b)
+- feat: 新增告警计划添加弹窗 (b220981)
+- feat(设备详情): 新增告警计划功能并优化信息展示布局 (1d0dc75)
+- style(组件样式): 优化雷达点云和设备详情页的样式 (bf5f9b0)
+
 ## v0.4.7 (2025-08-26)
 - refactor(pointCloud): 重构雷达点云组件为受控组件并优化交互 (73321e2)
 

+ 4 - 1
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,16 +57,17 @@ 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']
     FurnitureIcon: typeof import('./src/components/furnitureIcon/index.vue')['default']
     FurnitureItem: typeof import('./src/components/furnitureItem/index.vue')['default']
     FurnitureList: typeof import('./src/components/furnitureList/index.vue')['default']
-    RadarCloudPointsMap: typeof import('./src/components/radarCloudPointsMap/index.vue')['default']
     RadarPointCloud: typeof import('./src/components/radarPointCloud/index.vue')['default']
     RangePicker: typeof import('./src/components/rangePicker/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
+    ScrollContainer: typeof import('./src/components/scrollContainer/index.vue')['default']
   }
 }

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "ln-web",
-  "version": "0.4.7",
+  "version": "0.5.4",
   "private": true,
   "type": "module",
   "scripts": {

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

+ 46 - 0
src/api/alarm/index.ts

@@ -0,0 +1,46 @@
+import request from '@/request'
+import type * as TYPE from './types'
+
+/**
+ * 告警计划保存
+ */
+export const saveAlarmPlan = (
+  params: TYPE.AlarmPlanParams
+): Promise<ResponseData<TYPE.AlarmPlanResponseData>> => {
+  return request.post('/alarm/plan/save', params)
+}
+
+/**
+ * 告警事件类型下拉列表
+ */
+export const getAlarmEventTypeList = (): Promise<
+  ResponseData<TYPE.AlarmEventTypeListResponseData>
+> => {
+  return request.get('/alarm/plan/queryEventType')
+}
+
+/**
+ * 告警计划查询
+ */
+export const getAlarmPlanList = (params: {
+  clientId: string
+}): Promise<ResponseData<TYPE.AlarmPlanListResponseData>> => {
+  return request.post('/alarm/plan/query', params)
+}
+
+/**
+ * 告警计划删除
+ */
+export const deleteAlarmPlan = (params: { id: number }): Promise<ResponseData<null>> => {
+  return request.post('/alarm/plan/del', params)
+}
+
+/**
+ * 告警计划禁启用
+ */
+export const enableAlarmPlan = (params: {
+  id: number
+  enable: 0 | 1
+}): Promise<ResponseData<null>> => {
+  return request.post('/alarm/plan/enable', params)
+}

+ 93 - 0
src/api/alarm/types.ts

@@ -0,0 +1,93 @@
+/**
+ * 告警计划事件类型
+ * @example
+  1、2、3: { }
+  4、5、8: { start_time: string; end_time: string }
+   6、7: { start_time: string; end_time: string; count: number }
+  9: { time_threshold: number }
+  * @enum {string} 1 stay_detection 停留事件
+  * @enum {string} 2 retention_detection 滞留事件
+  * @enum {string} 3 toileting_detection 如厕事件
+  * @enum {string} 4 toileting_frequency 如厕频次统计
+  * @enum {string} 5 night_toileting_frequency 夜间如厕频次统计
+  * @enum {string} 8 bathroom_stay_frequency 卫生间频次统计
+  * @enum {string} 6 toileting_frequency_abnormal 如厕频次异常
+  * @enum {string} 7 night_toileting_frequency_abnormal 起夜异常
+  * @enum {string} 9 target_absence 异常消失
+ */
+export type EventValParam = {
+  start_time?: string // 统计开始时间
+  end_time?: string // 统计结束时间
+  count?: number // 异常阈值
+  time_threshold?: number // 异常消失时间阈值(单位:秒)
+}
+
+interface AlarmTimePlan {
+  id?: number // 	主键ID
+  startDate: string // 开始时间
+  stopDate: string // 结束时间
+  weekdays?: string // 每周的生效日期[1,2,3,6,7]
+  monthDays?: string // 每月的生效日期[1,2,3,15,20,25,26,27]
+  timeRange: string // 	每日生效时间[{"start_time": "00:00","end_time": "12:00"},{"start_time": "18:00","end_time": "23:59"}, ...]
+  createId?: number // 创建人ID
+  updateId?: number // 更新人ID
+  createTime?: string // 创建时间
+  updateTime?: string // 更新时间
+  isDeleted?: 0 | 1 // 是否删除(number)  0: 否  1: 是
+  remark?: string // 备注
+}
+/**
+ * 保存告警计划请求参数
+ * @see http://8.130.28.21:31090/doc.html#/%E9%97%A8%E6%88%B7%E6%9C%8D%E5%8A%A1/web%E7%AB%AF%E5%91%8A%E8%AD%A6%E7%9B%B8%E5%85%B3/savePlan
+ */
+export interface AlarmPlanParams {
+  alarmPlanId?: number // 告警计划ID
+  uuid?: string // 告警计划UUID
+  name: string // 告警计划名称
+  clientId: string // 设备ID
+  region: string // 检测区域 '[left, top, width, height]'
+  eventVal: number // 告警事件值
+  param: string // 告警参数 需要根据 eventVal 传对应的参数
+  alarmTimePlan: AlarmTimePlan // 告警时间计划
+  enable: 0 | 1 // 是否启用(number) 1: 启用 0: 禁用
+}
+
+/**
+ * 保存告警计划响应数据
+ */
+export interface AlarmPlanResponseData extends Omit<AlarmPlanParams, 'eventVal'> {
+  eventVal: string // 告警事件值
+  id: number // 告警计划ID
+  uuid: string // 告警计划UUID
+  createTime: string // 创建时间
+  updateTime: string // 更新时间
+}
+
+/**
+ * 告警事件类型下拉列表响应数据
+ */
+export interface AlarmEventTypeListResponseData {
+  id: number // 事件类型ID
+  eventStr: string // 事件字符串
+  eventVal: string // 事件值
+  eventDesc: string // 事件描述
+  remark: string // 备注
+}
+
+/**
+ * 告警计划查询响应数据
+ */
+export interface AlarmPlanListResponseData {
+  id: number // 告警计划ID
+  uuid: string // 告警计划UUID
+  name: string // 告警计划名称
+  clientId: string // 设备ID
+  region: string // 检测区域 '[left, top, width, height]'
+  eventVal: string // 告警事件值
+  param: string // 告警参数 需要根据 eventVal 传对应的参数
+  alarmTimePlan: AlarmTimePlan // 告警时间计划
+  enable: 0 | 1 // 是否启用(number) 1: 启用 0: 禁用
+  createTime: string // 创建时间
+  updateTime: string // 更新时间
+  remark: string // 备注
+}

+ 3 - 3
src/api/device/types.ts

@@ -187,8 +187,8 @@ export interface DeviceDetailData {
   userId: ID // 用户ID
   devName: string //设备名称
   devType: string // 设备类型
-  online: Switch // 设备状态:0-离线,1-在线
-  devWarn: Switch // 设备报警:0-正常,1-报警
+  online: SwitchType // 设备状态:0-离线,1-在线
+  devWarn: SwitchType // 设备报警:0-正常,1-报警
   software: string // 软件版本
   hardware: string // 硬件版本
   wifiName: string // 无线网络名称
@@ -210,7 +210,7 @@ export interface DeviceDetailData {
   signalTime: ID // 接收target时间
   northAngle: NorthAngle // 北向夹角 允许传值: 0,90,180,270
   activeTime: string // 激活时间
-  statusLight: Switch // 指示灯开关:0-关闭,1-开启
+  statusLight: SwitchType // 指示灯开关:0-关闭,1-开启
   createId: ID // 创建人
   updateId: ID // 修改人
   createTime: string // 创建时间

+ 5 - 12
src/api/stats/index.ts

@@ -8,16 +8,9 @@ export const statsFallQuery = (
   return request.post('/stats/fallQuery', params)
 }
 
-// 异常滞留统计
-export const statsAlarmRetentionQuery = (
-  params: TYPE.StatsAlarmRetentionQueryParams
-): Promise<ResponseData<TYPE.StatsAlarmRetentionQueryData>> => {
-  return request.post('/stats/alarmRetentionQuery', params)
-}
-
-// 一般滞留统计
-export const statsGeneralRetentionQuery = (
-  params: TYPE.StatsGeneralRetentionQueryParams
-): Promise<ResponseData<TYPE.StatsGeneralRetentionQueryData>> => {
-  return request.post('/stats/generalRetentionQuery', params)
+// 告警统计
+export const statsAlarmQuery = (
+  params: TYPE.AtatsAlarmQueryParams
+): Promise<ResponseData<TYPE.StatsAlarmQueryData>> => {
+  return request.post('/stats/alarmEventsQuery', params)
 }

+ 27 - 74
src/api/stats/types.ts

@@ -38,7 +38,7 @@ export interface StatsFallQueryDataRow {
   updateId: ID // 修改人
   createTime: string // 创建时间
   updateTime: string // 更新时间
-  isDeleted: Switch // 删除标记:0-未删除,1-已删除
+  isDeleted: SwitchType // 删除标记:0-未删除,1-已删除
   remark: ID // 备注
   eventListId: ID // 事件主键
   devId: ID // 设备Id
@@ -47,7 +47,7 @@ export interface StatsFallQueryDataRow {
   targetPoints: ID // target数组
   eventType: ID // 事件类型
   eventTypeName: string // 事件类型名称
-  isHandle: Switch // 是否处理:0-未处理,1-已处理
+  isHandle: SwitchType // 是否处理:0-未处理,1-已处理
 }
 
 /**
@@ -64,88 +64,41 @@ export interface StatsFallQueryData {
 }
 
 /**
- * 异常滞留入参
- * @description: 分页查询设备的异常滞留统计信息
- * @param devId 设备ID
- * @param tenantId 租户id
- * @param createTimeStart 起始时间 格式yyyy-MM-dd
- * @param createTimeEnd 结束时间 格式yyyy-MM-dd
- * @param pageNo 页码
- * @param pageSize 每页条数
- * @param eventType 事件类型 0:异常滞留 1:异常消失 2:异常起夜 3:异常入厕
+ * 告警统计入参
  */
-export interface StatsAlarmRetentionQueryParams extends StatsFallQueryParams {
-  eventType: ID // 事件类型 0:异常滞留 1:异常消失 2:异常起夜 3:异常入厕
-}
-
-/**
- * 异常滞留出参 rows
- */
-export interface StatsAlarmRetentionQueryDataRow {
-  alarmEventId: ID // 告警表主键ID
-  devId: ID // 设备表主键ID
-  pose: string // 姿势
-  poseName: string // 姿势名称
-  stayTimeId: string // 事件表主键id
-  eventType: string // 告警类型
-  eventTypeName: string // 告警类型名称
-  isHandle: Switch // 是否处理:0-未处理,1-已处理
-}
-
-/**
- * 异常滞留出参
- */
-export interface StatsAlarmRetentionQueryData {
-  rows: StatsAlarmRetentionQueryDataRow[]
-  total: number
-  pageNum: number
-  pageSize: number
-  outTotalPageNum: boolean
-  totalPageNum: number
-}
-
-/**
- * 一般滞留入参
- * @description: 分页查询设备的一般滞留统计信息
- * @param devId 设备ID
- * @param tenantId 租户id
- * @param createTimeStart 起始时间 格式yyyy-MM-dd
- * @param createTimeEnd 结束时间 格式yyyy-MM-dd
- * @param pageNo 页码
- * @param pageSize 每页条数
- * @param type 类型 0:一般滞留事件 2:马桶滞留事件
- */
-export interface StatsGeneralRetentionQueryParams extends StatsFallQueryParams {
-  type: ID // 类型 0:一般滞留事件 2:马桶滞留事件
+export interface AtatsAlarmQueryParams {
+  pageNo: number // 页码
+  pageSize: number // 每页条数
+  clientId?: ID // 设备ID
+  tenantId?: ID // 租户id
+  createTimeStart: string // 起始时间 格式yyyy-MM-dd
+  createTimeEnd: string // 结束时间 格式yyyy-MM-dd
+  eventType?: ID // 事件类型 0:跌倒 1:跌倒恢复 2:跌倒异常 3:跌倒异常恢复 4:跌倒异常恢复
 }
 
 /**
- * 一般滞留出参 rows
+ * 告警统计出参 rows
  */
-export interface StatsGeneralRetentionQueryDataRows {
-  createId: ID // 创建人
-  updateId: ID // 修改人
-  createTime: string // 创建时间
-  updateTime: string // 更新时间
-  isDeleted: Switch // 	删除标记:0-未删除,1-已删除
-  remark: string // 备注
-  stayTimeId: ID // stay_time表主键id
-  devId: ID // 设备表主键id
-  clientId: ID // 设备clientId
+export interface StatsAlarmQueryDataRow {
+  id: ID // 主键ID
+  clientId: ID // 设备ID
+  tenantId: ID // 租户ID
   devName: string // 设备名称
-  type: ID // 停留类型
-  typeName: string // 停留类型名称
-  enterTime: string // 进入时间
-  leaveTime: string // 离开时间
-  stayTime: number // 停留时长(秒)
-  childStayTime: StatsGeneralRetentionQueryDataRows[] // 子停留信息
+  uuid: string // 告警事件uuid
+  planUuid: string // 告警计划uuid
+  eventType: string // 事件类型
+  eventTypeName: string // 事件类型名称
+  info: string //事件内容
+  isHandle: SwitchType // 是否处理
+  createTime: ID // 事件发生时间
+  remark: string // 备注
 }
 
 /**
- * 一般滞留出参
+ * 告警统计出参
  */
-export interface StatsGeneralRetentionQueryData {
-  rows: StatsGeneralRetentionQueryDataRows[]
+export interface StatsAlarmQueryData {
+  rows: StatsAlarmQueryDataRow[]
   total: number
   pageNum: number
   pageSize: number

BIN
src/assets/furnitures/radar copy.png


BIN
src/assets/furnitures/radar.png


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

+ 39 - 0
src/components/scrollContainer/index.vue

@@ -0,0 +1,39 @@
+<template>
+  <div class="scroll-container">
+    <slot />
+  </div>
+</template>
+
+<script setup lang="ts">
+defineOptions({
+  name: 'ScrollContainer',
+})
+</script>
+
+<style scoped>
+.scroll-container {
+  overflow: auto;
+  position: relative;
+  padding-right: 4px;
+  padding-bottom: 4px;
+}
+
+.scroll-container::-webkit-scrollbar {
+  width: 4px;
+  height: 4px;
+  opacity: 0;
+  transition: opacity 0.3s ease;
+}
+
+.scroll-container.is-scrolling::-webkit-scrollbar {
+  opacity: 1;
+}
+
+.scroll-container::-webkit-scrollbar-thumb {
+  border-radius: 4px;
+  background: rgba(150, 150, 150, 0.5);
+  transition: background-color 0.3s ease;
+  border: none;
+  background-clip: padding-box;
+}
+</style>

+ 2 - 2
src/const/furniture.ts

@@ -173,7 +173,7 @@ export const furnitureIconSizeMap = {
     height: 80,
   },
   radar: {
-    width: 40,
-    height: 40,
+    width: 20,
+    height: 20,
   },
 }

+ 1 - 1
src/request/index.ts

@@ -31,7 +31,7 @@ service.interceptors.request.use(
     if (config.method === 'get') {
       config.params = {
         ...config.params,
-        _t: Date.now(),
+        // _t: Date.now(),
       }
     }
 

+ 3 - 1
src/types/global.d.ts

@@ -6,7 +6,7 @@ type ID = number | string | null
 /**
  * 开关数据类型
  */
-type Switch = 0 | 1
+type SwitchType = 0 | 1
 
 /**
  * 安装方式类型
@@ -48,3 +48,5 @@ interface ResponseData<T> {
   data: T
   traceId: string
 }
+
+type ID = number | string | null

+ 60 - 0
src/utils/index.ts

@@ -52,3 +52,63 @@ export function formatDateTime(d: Date) {
   const pad = (n: number) => String(n).padStart(2, '0')
   return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
 }
+
+/**
+ * 获取坐标位置
+ */
+/**
+ * 获取坐标位置
+ * @note 雷达的检测范围
+ * @param xstart x 开始坐标
+ * @param xend x 结束坐标
+ * @param ystart y 开始坐标
+ * @param yend y 结束坐标
+ * @note 配置家具时,点击的家具元素的坐标
+ * @param offsetLeft 元素基于父容器的X坐标
+ * @param offsetTop 元素基于父容器的Y坐标
+ */
+export const getOriginPosition = (
+  [xstart, xend, ystart, yend]: number[],
+  [offsetLeft, offsetTop]: number[]
+) => {
+  // 容器宽高
+  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 = 20
+  const radarHeight = 20
+
+  // 雷达基于原点的偏移量
+  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
+}

+ 750 - 99
src/views/device/detail/components/alarmPlanModal/index.vue

@@ -3,7 +3,7 @@
     <a-modal
       :get-container="() => $refs.mod"
       :open="props.open"
-      :title="title"
+      :title="modelTitle"
       :mask-closable="false"
       width="600px"
       @cancel="cancel"
@@ -29,17 +29,128 @@
           />
         </a-form-item>
 
-        <a-form-item label="检测区域" name="region"> 检测区域选择组件 </a-form-item>
+        <a-form-item
+          label="计划备注"
+          name="planName"
+          :rules="[{ required: true, message: '请输入计划备注' }]"
+        >
+          <a-input
+            v-model:value.trim="formState.remark"
+            placeholder="请输入备注"
+            :maxlength="200"
+            show-count
+            allow-clear
+          />
+        </a-form-item>
+
+        <a-form-item
+          label="触发阈值"
+          name="thresholdTime"
+          :rules="[{ required: true, message: '请输入触发阈值' }]"
+        >
+          <a-input-number
+            v-model:value.trim="formState.thresholdTime"
+            placeholder="请输入(默认300,需要大于0)"
+            min="0"
+            show-count
+            allow-clear
+            style="width: 100%"
+          >
+            <template #addonAfter>
+              <a-select v-model:value="thresholdTimeFormat" style="width: 80px">
+                <a-select-option value="s">秒</a-select-option>
+                <a-select-option value="min">分钟</a-select-option>
+                <a-select-option value="hour">小时</a-select-option>
+                <a-select-option value="day">天</a-select-option>
+              </a-select>
+            </template>
+          </a-input-number>
+        </a-form-item>
+
+        <a-form-item
+          label="归并时间"
+          name="mergeTime"
+          :rules="[{ required: true, message: '请输入归并时间' }]"
+        >
+          <a-input-number
+            v-model:value.trim="formState.mergeTime"
+            placeholder="请输入(默认30,需要大于0)"
+            min="0"
+            show-count
+            allow-clear
+            style="width: 100%"
+          />
+        </a-form-item>
 
         <a-form-item
           label="事件类型"
           name="eventType"
           :rules="[{ required: true, message: '请选择事件类型' }]"
         >
-          <a-select v-model:value="formState.eventType" placeholder="请选择事件类型">
-            <a-select-option value="1">跌倒</a-select-option>
-            <a-select-option value="2">滞留</a-select-option>
-          </a-select>
+          <a-select
+            v-model:value="formState.eventType"
+            :options="eventTypeList"
+            placeholder="请选择事件类型"
+          />
+
+          <a-form-item-rest v-if="formState.eventType && ![1, 2, 3].includes(formState.eventType)">
+            <div class="eventTypeBox">
+              <div v-if="[4, 5, 6, 7, 8].includes(formState.eventType)" class="eventTypeBox-item">
+                <span class="eventTypeBox-item-label">统计时间:</span>
+                <a-form-item
+                  name="statisticsTime"
+                  :rules="[{ required: true, message: '请选择统计时间' }]"
+                >
+                  <a-time-range-picker
+                    v-model:value="formState.statisticsTime"
+                    valueFormat="HH:mm"
+                    format="HH:mm"
+                    style="width: 100%"
+                  />
+                </a-form-item>
+              </div>
+
+              <div v-if="[6, 7].includes(formState.eventType)" class="eventTypeBox-item">
+                <span class="eventTypeBox-item-label">异常阈值:</span>
+                <a-form-item name="count" :rules="[{ required: true, message: '请输入异常阈值' }]">
+                  <a-input-number
+                    v-model:value.trim="formState.count"
+                    placeholder="请输入(默认3,需要大于0)"
+                    min="0"
+                    show-count
+                    allow-clear
+                    style="width: 100%"
+                  />
+                </a-form-item>
+              </div>
+
+              <div v-if="formState.eventType === 9" class="eventTypeBox-item">
+                <span class="eventTypeBox-item-labelend">异常消失时间阈值:</span>
+                <a-form-item
+                  name="timeThreshold"
+                  :rules="[{ required: true, message: '请输入异常消失时间阈值' }]"
+                >
+                  <a-input-number
+                    v-model:value.trim="formState.timeThreshold"
+                    placeholder="请输入(默认300,需要大于0)"
+                    min="0"
+                    show-count
+                    allow-clear
+                    style="width: 100%"
+                  >
+                    <template #addonAfter>
+                      <a-select v-model:value="timeThresholdFormat" style="width: 80px">
+                        <a-select-option value="s">秒</a-select-option>
+                        <a-select-option value="min">分钟</a-select-option>
+                        <a-select-option value="hour">小时</a-select-option>
+                        <a-select-option value="day">天</a-select-option>
+                      </a-select>
+                    </template>
+                  </a-input-number>
+                </a-form-item>
+              </div>
+            </div>
+          </a-form-item-rest>
         </a-form-item>
 
         <a-form-item
@@ -50,45 +161,26 @@
           <a-range-picker
             v-model:value="formState.planTime"
             style="width: 100%"
-            show-time
-            valueFormat="YYYY-MM-DD HH:mm:ss"
+            :show-time="false"
+            valueFormat="YYYY-MM-DD"
           />
         </a-form-item>
 
-        <a-form-item label="生效方式" name="firmwareVersion">
-          <a-radio-group
-            v-model:value="formState.effectType"
-            name="radioGroup"
-            @change="effectTypeChange"
-          >
-            <a-radio value="week">按周</a-radio>
-            <a-radio value="mouth">按月</a-radio>
-          </a-radio-group>
-        </a-form-item>
-
-        <a-form-item label="生效范围" name="firmwareVersion">
-          <a-checkbox
-            v-model:checked="checkState.checkAll"
-            :indeterminate="checkState.indeterminate"
-            @change="onCheckAllChange"
-          >
-            全选
-          </a-checkbox>
-          <a-checkbox-group v-model:value="formState.effectTimeRanges" :options="plainOptions" />
-        </a-form-item>
-
         <a-form-item label="生效时段">
           <div style="display: flex; align-items: center; gap: 8px">
             <a-time-range-picker
               v-model:value="formState.effectTimeFrame"
-              valueFormat="HH:mm:ss"
+              valueFormat="HH:mm"
+              format="HH:mm"
               style="width: 100%"
             />
             <a-button size="small" type="link" @click="addEffectTime">添加</a-button>
           </div>
           <div style="margin-top: 12px">
-            <span v-if="!formState.effectTimeFrames.length" style="color: #aaa; font-size: 12px"
-              >暂无生效时段</span
+            <span
+              v-if="formState.effectTimeFrames && !formState.effectTimeFrames.length"
+              style="color: #aaa; font-size: 14px"
+              >⚠️暂无生效时段</span
             >
             <a-space wrap v-else>
               <a-tag
@@ -102,8 +194,82 @@
             </a-space>
           </div>
         </a-form-item>
+
+        <a-form-item label="生效方式">
+          <a-form-item-rest>
+            <a-radio-group
+              v-model:value="formState.effectType"
+              name="radioGroup"
+              @change="effectTypeChange"
+            >
+              <a-radio value="week">按周</a-radio>
+              <a-radio value="month">按月</a-radio>
+            </a-radio-group>
+            <a-checkbox
+              v-model:checked="checkState.checkAll"
+              :indeterminate="checkState.indeterminate"
+              @change="onCheckAllChange"
+            >
+              全选
+            </a-checkbox>
+            <a-checkbox-group v-model:value="formState.effectTimeRanges" :options="plainOptions" />
+          </a-form-item-rest>
+        </a-form-item>
+
+        <a-form-item
+          v-if="[1, 2, 3, 9].includes(formState?.eventType as number)"
+          label="检测区域"
+          name="region"
+          style="user-select: none"
+        >
+          <span style="user-select: none">框选区域 {{ formState.region }}</span>
+          <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)"
+                    >
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </a-form-item-rest>
+        </a-form-item>
+
         <a-form-item label="是否启用">
-          <a-switch v-model:checked="formState.isEnable" />
+          <a-switch v-model:checked="formState.enable" />
         </a-form-item>
       </a-form>
 
@@ -118,10 +284,10 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, watch } from 'vue'
+import { ref, reactive, watch, computed } from 'vue'
 import { message, type FormInstance } from 'ant-design-vue'
-// import * as deviceApi from '@/api/device'
-// import type { Rule } from 'ant-design-vue/es/form'
+import * as alarmApi from '@/api/alarm'
+import { getOriginPosition } from '@/utils/index'
 
 defineOptions({
   name: 'AlarmPlanModal',
@@ -129,9 +295,48 @@ defineOptions({
 
 const formRef = ref<FormInstance>()
 
+type AlarmPlan = {
+  id: number
+  uuid: ID
+  name: string
+  clientId: string
+  enable: SwitchType
+  region: string
+  eventVal: number
+  alarmTimePlanId: ID
+  thresholdTime: ID
+  mergeTime: ID
+  param: string
+  createTime: string
+  updateTime: string
+  remark: string | null
+  alarmTimePlan: {
+    createId: ID
+    updateId: ID
+    createTime: ID
+    updateTime: ID
+    isDeleted: SwitchType | null
+    remark: ID
+    id: ID
+    startDate: string
+    stopDate: string
+    timeRange: string
+    monthDays: string
+    weekdays: string
+  }
+}
+
 type Props = {
   open: boolean
   title?: string
+  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
@@ -141,68 +346,166 @@ const emit = defineEmits<{
 const props = withDefaults(defineProps<Props>(), {
   open: false,
   title: '告警计划',
+  clientId: '',
+  alarmPlanId: null,
+  data: undefined,
 })
 
+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)
+})
+
+// 获取原点坐标
+const { originX, originY } = getOriginPosition(props.area!.ranges, [0, 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[]>([])
+
+// 区块拖动
+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 - originX
+    block.oy = 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(0, Math.min(initialWidth + deltaX, rect.width - block.x))
+    block.height = Math.max(0, 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 mouthOptions = [
-  '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',
-  '28',
-  '29',
-  '30',
-  '31',
+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,
+  28, 29, 30, 31,
 ]
 
 type FormState = {
   planName: string // 计划名称
-  region: string[] // 检测区域 [left, top, width, height]
-  eventType: string | null // 事件类型
+  region: number[] // 检测区域 [left, top, width, height]
+  eventType: number | null // 事件类型
+  thresholdTime: number | null // 触发阈值
+  mergeTime: number | null // 归并时间
   planTime: string[] // 计划时间
   effectType: 'week' | 'month' // 生效方式 周week、月month
-  effectTimeRanges: string[] // 生效范围 周 1-7、月 1-31
+  effectTimeRanges: (number | string)[] // 生效范围 周 1-7、月 1-31
   effectTimeFrame: string[] // 生效时段 单条 00:00:00 - 23:59:59
   effectTimeFrames: { startTime: string; endTime: string }[] // 生效时段 多条
-  isEnable: boolean // 是否启用
+  enable: boolean // 是否启用
+  statisticsTime: string[] // 统计时间
+  count?: number | null // 异常阈值
+  timeThreshold?: number | null // 异常消失时间阈值(单位:秒)
+  remark?: string // 备注
 }
 
 const formState = reactive<FormState>({
   planName: '',
-  region: [],
-  eventType: '',
+  region: [0, 0, 50, 50],
+  eventType: null,
+  thresholdTime: 300,
+  mergeTime: 30,
   planTime: [],
   effectType: 'week',
   effectTimeRanges: weekOptions,
   effectTimeFrame: [],
   effectTimeFrames: [],
-  isEnable: true,
+  enable: true,
+  statisticsTime: [],
+  count: 3,
+  timeThreshold: 300,
 })
 
-const plainOptions = ref<string[]>(weekOptions)
+const initBlocks = () => {
+  blocks.value = [
+    {
+      x: 0,
+      y: 0,
+      ox: formState.region[0] ?? 0,
+      oy: formState.region[1] ?? 0,
+      width: formState.region[2] ?? 50,
+      height: formState.region[3] ?? 50,
+    },
+  ]
+}
+initBlocks()
+
+const thresholdTimeFormat = ref<'s' | 'min' | 'hour' | 'day'>('s') // 触发阈值 额外选择器
+const timeThresholdFormat = ref<'s' | 'min' | 'hour' | 'day'>('s') // 异常消失时间阈值 额外选择器
+
+const plainOptions = ref<(number | string)[]>(weekOptions)
 const checkState = reactive({
   indeterminate: true,
   checkAll: false,
@@ -215,10 +518,31 @@ const onCheckAllChange = (e: Event) => {
   formState.effectTimeRanges = checked ? [...plainOptions.value] : []
 }
 
+// 星期映射数字
+const weekToNumMap: Record<string, number> = {
+  周一: 1,
+  周二: 2,
+  周三: 3,
+  周四: 4,
+  周五: 5,
+  周六: 6,
+  周日: 7,
+}
+// 数字映射星期
+const numToWeekMap: Record<number, string> = {
+  1: '周一',
+  2: '周二',
+  3: '周三',
+  4: '周四',
+  5: '周五',
+  6: '周六',
+  7: '周日',
+}
+
 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 // 设置全选按钮的选中状态
   },
   {
@@ -226,6 +550,104 @@ watch(
   }
 )
 
+const safeParse = <T,>(str: string | undefined | null, fallback: T): T => {
+  try {
+    return JSON.parse(str ?? JSON.stringify(fallback)) as T
+  } catch {
+    return fallback
+  }
+}
+
+interface ParamType {
+  start_time?: string
+  end_time?: string
+  count?: number
+  time_threshold?: number
+}
+
+interface TimeFrame {
+  start_time?: string
+  end_time?: string
+}
+
+interface AlarmTimePlan {
+  startDate?: string
+  stopDate?: string
+  weekdays?: string
+  monthDays?: string
+  timeRange?: string
+}
+
+interface SourceData {
+  name?: string
+  remark?: string
+  thresholdTime?: number
+  mergeTime?: number
+  eventVal?: number | null
+  param?: string
+  alarmTimePlan?: AlarmTimePlan
+  region?: string
+  enable?: number
+}
+
+const echoFormState = (val: SourceData) => {
+  const paramObj = safeParse<ParamType>(val.param, {})
+  const weekdays = safeParse<string[]>(val.alarmTimePlan?.weekdays, [])
+  const monthDays = safeParse<string[]>(val.alarmTimePlan?.monthDays, [])
+  const timeFrames = safeParse<TimeFrame[]>(val.alarmTimePlan?.timeRange, [])
+  plainOptions.value = weekdays.length === 0 ? monthOptions : weekOptions
+
+  return {
+    planName: val.name ?? '',
+    remark: val.remark ?? '',
+    thresholdTime: typeof val.thresholdTime === 'number' ? val.thresholdTime : null,
+    mergeTime: typeof val.mergeTime === 'number' ? val.mergeTime : null,
+    eventType: val.eventVal ?? null,
+    statisticsTime: [paramObj.start_time ?? null, paramObj.end_time ?? null] as string[],
+    count: paramObj.count ?? null,
+    timeThreshold: paramObj.time_threshold ?? null,
+    planTime: [val.alarmTimePlan?.startDate ?? '', val.alarmTimePlan?.stopDate ?? ''],
+    effectType: (weekdays.length === 0 ? 'month' : 'week') as 'week' | 'month',
+    effectTimeRanges:
+      weekdays.length === 0
+        ? monthDays
+        : weekdays.map((item: string) => numToWeekMap[Number(item)]),
+    effectTimeFrames: Array.isArray(timeFrames)
+      ? timeFrames.map((item) => ({
+          startTime: item?.start_time ?? '',
+          endTime: item?.end_time ?? '',
+        }))
+      : [],
+    region: JSON.parse(val.region ?? '[0, 0, 50, 50]'),
+    enable: val?.enable === 1,
+  }
+}
+
+watch(
+  () => props.open,
+  (value) => {
+    const val = props.data as SourceData
+    console.log('🌸🌸 监听data用于编辑回显数据 🌸🌸', value, val)
+    if (value && val) {
+      formState.planName = echoFormState(val).planName
+      formState.remark = echoFormState(val).remark
+      formState.thresholdTime = echoFormState(val).thresholdTime
+      formState.mergeTime = echoFormState(val).mergeTime
+      formState.eventType = echoFormState(val).eventType
+      formState.statisticsTime = echoFormState(val).statisticsTime
+      formState.count = echoFormState(val).count
+      formState.timeThreshold = echoFormState(val).timeThreshold
+      formState.planTime = echoFormState(val).planTime
+      formState.effectType = echoFormState(val).effectType
+      formState.effectTimeRanges = echoFormState(val).effectTimeRanges
+      formState.effectTimeFrames = echoFormState(val).effectTimeFrames
+      formState.region = echoFormState(val).region
+      initBlocks()
+    }
+  },
+  { immediate: true }
+)
+
 // 生效方式变化 周week、月month
 const effectTypeChange = (e: Event) => {
   const value = (e.target as HTMLInputElement).value
@@ -234,9 +656,9 @@ const effectTypeChange = (e: Event) => {
   if (value === 'week') {
     plainOptions.value = weekOptions
     formState.effectTimeRanges = weekOptions
-  } else if (value === 'mouth') {
-    plainOptions.value = mouthOptions
-    formState.effectTimeRanges = mouthOptions
+  } else if (value === 'month') {
+    plainOptions.value = monthOptions
+    formState.effectTimeRanges = monthOptions
   }
   /* 置全选按钮的状态 */
   checkState.indeterminate =
@@ -264,9 +686,71 @@ const deleteEffectTimeItem = (e: Event, index: number) => {
   formState.effectTimeFrames.splice(index, 1)
 }
 
+// 关闭弹窗
 const cancel = () => {
   formRef?.value?.resetFields()
   emit('update:open', false)
+  // 重置表单
+  formState.planName = ''
+  formState.region = [0, 0, 50, 50]
+  formState.eventType = null
+  formState.thresholdTime = 300
+  formState.mergeTime = 30
+  formState.planTime = []
+  formState.effectType = 'week'
+  plainOptions.value = weekOptions
+  formState.effectTimeRanges = weekOptions
+  formState.effectTimeFrame = []
+  formState.effectTimeFrames = []
+  formState.enable = true
+  formState.statisticsTime = []
+  formState.count = 3
+  formState.timeThreshold = 300
+  formState.remark = ''
+}
+
+const eventTypeList = ref<{ label: string; value: string }[]>([])
+// 获取事件类型下拉列表
+const fetchEventTypeList = async () => {
+  try {
+    const res = await alarmApi.getAlarmEventTypeList()
+    console.log('获取事件类型下拉列表成功✅', res)
+    const data = res.data
+    eventTypeList.value =
+      (Array.isArray(data) &&
+        data.map((item) => ({
+          label: item.eventDesc,
+          value: item.eventVal,
+        }))) ||
+      []
+  } catch (err) {
+    console.log('获取事件类型下拉列表失败❌', err)
+  }
+}
+fetchEventTypeList()
+
+function thresholdTimeFormatValue() {
+  if (thresholdTimeFormat.value === 's') {
+    return Number(formState.thresholdTime) // 触发阈值
+  } else if (thresholdTimeFormat.value === 'min') {
+    return Number(formState.thresholdTime) * 60 // 触发阈值
+  } else if (thresholdTimeFormat.value === 'hour') {
+    return Number(formState.thresholdTime) * 60 * 60 // 触发阈值
+  } else if (thresholdTimeFormat.value === 'day') {
+    return Number(formState.thresholdTime) * 24 * 60 * 60 // 触发阈值
+  }
+}
+
+function timeThresholdFormatValue() {
+  if (timeThresholdFormat.value === 's') {
+    return Number(formState.timeThreshold) // 触发阈值
+  } else if (timeThresholdFormat.value === 'min') {
+    return Number(formState.timeThreshold) * 60 // 触发阈值
+  } else if (timeThresholdFormat.value === 'hour') {
+    return Number(formState.timeThreshold) * 60 * 60 // 触发阈值
+  } else if (timeThresholdFormat.value === 'day') {
+    return Number(formState.timeThreshold) * 24 * 60 * 60 // 触发阈值
+  }
 }
 
 const submitLoading = ref(false)
@@ -276,24 +760,92 @@ const submit = () => {
     ?.validate()
     .then(() => {
       console.log('校验通过', formState)
-      // submitLoading.value = true
-      // deviceApi
-      //   .addDevice({
-      //     clientId: formState.deviceId,
-      //     devType: formState.deviceType,
-      //     software: formState.firmwareVersion,
-      //     tenantId: formState.tenantId,
-      //   })
-      //   .then((res) => {
-      //     console.log('添加成功', res)
-      //     submitLoading.value = false
-      //     message.success('添加成功')
-      //     emit('success')
-      //     cancel()
-      //   })
-      //   .catch(() => {
-      //     submitLoading.value = false
-      //   })
+      let paramData = {}
+      if ([1, 2, 3].includes(formState.eventType as number)) {
+        paramData = {}
+        console.log('🔥paramData🔥', paramData)
+      } else if ([4, 5, 8].includes(formState.eventType as number)) {
+        paramData = {
+          start_time: formState.statisticsTime[0],
+          end_time: formState.statisticsTime[1],
+        }
+        console.log('🔥paramData🔥', paramData)
+      } else if ([6, 7].includes(formState.eventType as number)) {
+        paramData = {
+          start_time: formState.statisticsTime[0],
+          end_time: formState.statisticsTime[1],
+          count: isNaN(Number(formState.count)) ? 0 : Number(formState.count),
+        }
+        console.log('🔥paramData🔥', paramData)
+      } else if ([9].includes(formState.eventType as number)) {
+        paramData = {
+          time_threshold: isNaN(Number(formState.timeThreshold)) ? 0 : timeThresholdFormatValue(),
+        }
+        console.log('🔥paramData🔥', paramData)
+      }
+
+      const params = {
+        alarmPlanId: props.alarmPlanId || undefined, // 告警计划ID 编辑时传入
+        clientId: props.clientId, // 设备ID
+        name: formState.planName, // 计划名称
+        remark: formState.remark || '', // 备注
+        thresholdTime: thresholdTimeFormatValue(), // 触发阈值
+        mergeTime: Number(formState.mergeTime) || 30, // 归并时间
+        eventVal: formState.eventType as number, // 事件类型 与 param 有联动关系
+        param: JSON.stringify(paramData), // 事件参数 与 eventVal 有联动关系
+        region: [1, 2, 3, 9].includes(formState.eventType as number)
+          ? JSON.stringify(formState.region)
+          : [], // 检测区域
+        enable: Number(formState.enable) as 0 | 1, // 是否启用 0否 1是
+
+        // 生效方式
+        alarmTimePlan: {
+          startDate: formState.planTime[0], // 计划开始时间
+          stopDate: formState.planTime[1], // 计划结束时间
+          // 生效时段
+          timeRange: JSON.stringify(
+            formState.effectTimeFrames.map((item) => ({
+              start_time: item.startTime,
+              end_time: item.endTime,
+            }))
+          ),
+          monthDays: JSON.stringify(
+            formState.effectType === 'month' ? formState.effectTimeRanges : []
+          ),
+          weekdays: JSON.stringify(
+            formState.effectType === 'week'
+              ? formState.effectTimeRanges.map((item) => weekToNumMap[item])
+              : []
+          ),
+        },
+      }
+      console.log('🚀🚀🚀提交参数', params)
+      if (formState.effectTimeFrames.length === 0) {
+        message.warn('请添加生效时段')
+        return
+      }
+      if (formState.effectTimeRanges.length === 0) {
+        message.warn('请选择生效方式的范围')
+        return
+      }
+      // if ([1, 2, 3, 9].includes(formState.eventType as number) && formState.region.length !== 4) {
+      //   message.warn('请选择检测区域')
+      //   return
+      // }
+
+      submitLoading.value = true
+      alarmApi
+        .saveAlarmPlan(params)
+        .then((res) => {
+          console.log('添加成功', res)
+          submitLoading.value = false
+          message.success('添加成功')
+          emit('success')
+          cancel()
+        })
+        .catch(() => {
+          submitLoading.value = false
+        })
     })
     .catch((err) => {
       console.log('校验失败', err)
@@ -320,4 +872,103 @@ const submit = () => {
 :deep(.ant-tag) {
   margin-inline-end: 0 !important;
 }
+
+.eventTypeBox {
+  margin-top: 12px;
+  color: #555;
+  font-size: 14px;
+  padding: 20px 12px;
+  background: #fafafa;
+  border-radius: 8px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+  &-item {
+    margin-bottom: 12px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+
+    &-label {
+      width: 100px;
+    }
+    &-labelend {
+      width: 140px;
+    }
+    &:last-child {
+      margin-bottom: 0;
+    }
+    :deep(.ant-form-item) {
+      flex: 1;
+      margin-bottom: 0;
+    }
+  }
+}
+
+.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>

+ 31 - 82
src/views/device/detail/components/deviceAreaConfig/index.vue

@@ -17,7 +17,7 @@
         <div>
           <div class="viewer-header-title">家具配置</div>
           <div class="viewer-header-subtitle">
-            <span>检测范围 {{ areaWidth }} x {{ areaHeight }} cm</span>
+            <span>检测范围 {{ areaWidth }} x {{ areaHeight }} cm {{ props.ranges }}</span>
           </div>
         </div>
         <div class="viewer-header-extra">
@@ -168,7 +168,9 @@
       <div class="viewer-header">
         <div>
           <div class="viewer-header-title">屏蔽子区域配置</div>
-          <div class="viewer-header-subtitle">检测范围 {{ areaWidth }} x {{ areaHeight }} cm</div>
+          <div class="viewer-header-subtitle"
+            >检测范围 {{ areaWidth }} x {{ areaHeight }} cm {{ props.ranges }}</div
+          >
         </div>
         <div class="viewer-header-extra">
           <a-space>
@@ -365,6 +367,7 @@ import {
   DeleteOutlined,
   QuestionCircleOutlined,
 } from '@ant-design/icons-vue'
+import { getOriginPosition } from '@/utils'
 
 defineOptions({
   name: 'deviceAreaConfig',
@@ -375,6 +378,7 @@ type Props = {
   // x,y的范围,用于初始化画布的大小
   length: number
   width: number
+  ranges: number[] // 区域范围
 }
 const emit = defineEmits<{
   (e: 'success', value: void): void
@@ -383,6 +387,7 @@ const props = withDefaults(defineProps<Props>(), {
   devId: '',
   length: 0, // 区域宽度
   width: 0, // 区域高度
+  ranges: () => [], // 区域范围
 })
 
 const deviceRoomId = ref('')
@@ -457,10 +462,10 @@ const fetchRoomLayout = async () => {
         blocks.value.push({
           // 本地需要使用的数据
           id: nanoid(),
-          x: item.startXx + getOriginPosition().originX,
-          y: getOriginPosition().originY - item.startYy,
-          ox: item.startXx + getOriginPosition().originX - getOriginPosition().originX,
-          oy: getOriginPosition().originY - item.startYy - getOriginPosition().originY,
+          x: item.startXx + originX,
+          y: originY - item.startYy,
+          ox: item.startXx + originX - originX,
+          oy: originY - item.startYy - originY,
           width: Math.abs(item.stopXx - item.startXx),
           height: Math.abs(item.stopYy - item.startYy),
           isDragging: false,
@@ -528,7 +533,6 @@ const addHnadler = (icon: FurnitureIconType) => {
     }
   }
 
-  const { originOffsetX, originOffsetY } = getOriginPosition()
   // 家具原始宽高
   const originWidth = furnitureIconSizeMap[icon].width || 30
   const originHeight = furnitureIconSizeMap[icon].height || 30
@@ -644,7 +648,6 @@ const onDragstartListItem = (event: DragEvent, item: CanvaseItem) => {
 // 家具列表元素结束拖拽
 const onDragendEndListItem = (event: DragEvent, item: CanvaseItem) => {
   item.isDragging = false
-  const { originOffsetX, originOffsetY } = getOriginPosition()
   if (currentDragItem.value) {
     currentDragItem.value.x = originOffsetX
     currentDragItem.value.y = originOffsetY
@@ -850,8 +853,8 @@ const handleMouseUp = () => {
       id: nanoid(),
       x: Math.round(Math.min(startX, currentX)),
       y: Math.round(Math.min(startY, currentY)),
-      ox: Math.round(Math.min(startX, currentX)) - getOriginPosition().originX,
-      oy: Math.round(Math.min(startY, currentY)) - getOriginPosition().originY,
+      ox: Math.round(Math.min(startX, currentX)) - originX,
+      oy: Math.round(Math.min(startY, currentY)) - originY,
       width,
       height,
       isDragging: false,
@@ -860,10 +863,10 @@ const handleMouseUp = () => {
       isTracking: false,
       isFalling: false,
       // 接口用
-      startXx: Math.round(Math.min(startX, currentX)) - getOriginPosition().originX,
-      stopXx: Math.round(Math.min(startX, currentX)) - getOriginPosition().originX + width,
-      startYy: Math.round(Math.min(startY, currentY)) - getOriginPosition().originY,
-      stopYy: Math.round(Math.min(startY, currentY)) - getOriginPosition().originY + height,
+      startXx: Math.round(Math.min(startX, currentX)) - originX,
+      stopXx: Math.round(Math.min(startX, currentX)) - originX + width,
+      startYy: Math.round(Math.min(startY, currentY)) - originY,
+      stopYy: Math.round(Math.min(startY, currentY)) - originY + height,
       startZz: 0,
       stopZz: 0,
       isLowSnr: 0,
@@ -901,8 +904,8 @@ const startDrag = (block: BlockItem, e: MouseEvent) => {
 
     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
+    block.ox = block.x - originX
+    block.oy = originY - block.y
     block.startXx = block.ox
     block.stopXx = block.ox + block.width
     block.startYy = block.oy
@@ -962,68 +965,14 @@ const selectBlock = (block: BlockItem) => {
 //   }
 // }
 
-/**
- * 获取坐标位置
- * @returns
- * * width: 容器宽度
- * * heigt: 容器高度
- * * originX: 原点X坐标
- * * originY: 原点Y坐标
- * * offsetX: 元素基于父容器的偏移量 X坐标
- * * offsetY: 元素基于父容器的偏移量 Y坐标
- * * originOffsetX: 元素基于原点的偏移量 X坐标
- * * originOffsetY: 元素基于原点的偏移量 Y坐标
- * * radarX: 雷达X坐标
- * * radarY: 雷达Y坐标
- */
-const getOriginPosition = () => {
-  // 地图尺寸
-  const containerWidth = areaWidth.value
-  const containerHeight = areaHeight.value
-
-  // 地图原点坐标
-  const originX = containerWidth / 2
-  const originY = containerHeight / 2
-
-  // 元素基于父容器的偏移量
-  const offsetX = (currentDragItem.value && currentDragItem.value?.left) || 0
-  const offsetY = (currentDragItem.value && currentDragItem.value?.top) || 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),
-    originY: Math.round(originY),
-    offsetX: Math.round(offsetX),
-    offsetY: Math.round(offsetY),
-    originOffsetX: Math.round(originOffsetX),
-    originOffsetY: Math.round(originOffsetY),
-    radarX,
-    radarY,
-    radarWidth,
-    radarHeight,
-  }
-  console.log('getOriginPosition', data)
-
-  return data
-}
+const { originX, originY, originOffsetX, originOffsetY, radarX, radarY } = getOriginPosition(
+  props.ranges,
+  [currentDragItem.value?.left as number, currentDragItem.value?.top as number]
+)
 
 // 初始化添加雷达图标
 const initRadarIcon = () => {
   console.log('initRadarIcon', mapCanvasList.value, furnitureItems.value)
-  const { radarX, radarY, originOffsetX, originOffsetY } = getOriginPosition()
   // 在家具地图添加雷达图标
   mapCanvasList.value.push({
     name: '雷达',
@@ -1166,20 +1115,20 @@ const startResize = (block: BlockItem, e: MouseEvent) => {
 const blockInputPressEnter = (e: Event, el: BlockItem, attr: string) => {
   if (attr === 'startXx') {
     el.startXx = Number(el[attr as keyof BlockItem])
-    el.x = el.startXx + getOriginPosition().originX
+    el.x = el.startXx + originX
   }
   if (attr === 'stopXx') {
     el.stopXx = Number(el[attr as keyof BlockItem])
-    el.width = el.stopXx + getOriginPosition().originX - el.width
+    el.width = el.stopXx + originX - el.width
   }
 
   if (attr === 'startYy') {
     el.startYy = Number(el[attr as keyof BlockItem])
-    el.x = el.startYy + getOriginPosition().originY
+    el.x = el.startYy + originY
   }
   if (attr === 'stopYy') {
     el.stopYy = Number(el[attr as keyof BlockItem])
-    el.height = el.stopYy + getOriginPosition().originY - el.height
+    el.height = el.stopYy + originY - el.height
   }
 }
 
@@ -1187,20 +1136,20 @@ const blockInputBlur = (e: Event, el: BlockItem, attr: string) => {
   console.log('blockInputBlur', e, el, attr)
   if (attr === 'startXx') {
     el.startXx = Number(el[attr as keyof BlockItem])
-    el.x = el.startXx + getOriginPosition().originX
+    el.x = el.startXx + originX
   }
   if (attr === 'stopXx') {
     el.stopXx = Number(el[attr as keyof BlockItem])
-    el.width = el.stopXx + getOriginPosition().originX - el.width
+    el.width = el.stopXx + originX - el.width
   }
 
   if (attr === 'startYy') {
     el.startYy = Number(el[attr as keyof BlockItem])
-    el.x = el.startYy + getOriginPosition().originY
+    el.x = el.startYy + originY
   }
   if (attr === 'stopYy') {
     el.stopYy = Number(el[attr as keyof BlockItem])
-    el.height = el.stopYy + getOriginPosition().originY - el.height
+    el.height = el.stopYy + originY - el.height
   }
 }
 

+ 9 - 0
src/views/device/detail/components/deviceConfig/index.vue

@@ -28,6 +28,7 @@
         :dev-id="props.data.devId"
         :length="props.data.length"
         :width="props.data.width"
+        :ranges="[props.data.xStart, props.data.xEnd, props.data.yStart, props.data.yEnd]"
         @success="deviceAreaConfigSuccess"
       ></deviceAreaConfig>
     </template>
@@ -52,6 +53,10 @@ type Props = {
     clientId: string
     length: number
     width: number
+    xStart: number
+    xEnd: number
+    yStart: number
+    yEnd: number
   }
 }
 
@@ -68,6 +73,10 @@ const props = withDefaults(defineProps<Props>(), {
     clientId: '',
     length: 0,
     width: 0,
+    xStart: 0,
+    xEnd: 0,
+    yStart: 0,
+    yEnd: 0,
   }),
 })
 

+ 19 - 33
src/views/device/detail/components/deviceStatsDrawer/const.ts

@@ -30,55 +30,41 @@ export const fallColumns = [
   },
 ]
 
-//异常滞留统计列表表头
-export const alarmRetentionColumns = [
+// 告警统计列表表头
+export const alarmColumns = [
   {
-    title: '告警类型',
+    title: '发生时间',
+    dataIndex: 'createTime',
+    key: 'createTime',
+    align: 'center',
+    width: 150,
+  },
+  {
+    title: '事件类型',
     dataIndex: 'eventType',
     key: 'eventType',
     align: 'center',
     width: 150,
   },
   {
-    title: '姿势',
-    dataIndex: 'pose',
-    key: 'pose',
+    title: '事件内容',
+    dataIndex: 'info',
+    key: 'info',
     align: 'center',
-    width: 150,
+    // width: 200,
   },
   {
     title: '处理状态',
-    dataIndex: 'isHandle',
     key: 'isHandle',
+    dataIndex: 'isHandle',
     align: 'center',
     width: 150,
   },
-]
-
-// 一般滞留统计列表表头
-export const generalRetentionColumns = [
   {
-    title: '滞留类型',
-    dataIndex: 'type',
-    key: 'type',
-    align: 'center',
-  },
-  {
-    title: '进入时间',
-    dataIndex: 'enterTime',
-    key: 'enterTime',
-    align: 'center',
-  },
-  {
-    title: '离开时间',
-    dataIndex: 'leaveTime',
-    key: 'leaveTime',
-    align: 'center',
-  },
-  {
-    title: '停留时间',
-    dataIndex: 'stayTime',
-    key: 'stayTime',
+    title: '备注',
+    key: 'remark',
+    dataIndex: 'remark',
     align: 'center',
+    width: 150,
   },
 ]

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

@@ -11,8 +11,7 @@
     <template #extra>
       <a-radio-group v-model:value="tableType" button-style="solid" size="small">
         <a-radio-button value="fall">跌倒统计</a-radio-button>
-        <a-radio-button value="alarmRetention">异常滞留</a-radio-button>
-        <a-radio-button value="generalRetention">一般滞留</a-radio-button>
+        <a-radio-button value="alarm">告警统计</a-radio-button>
       </a-radio-group>
     </template>
 
@@ -26,26 +25,16 @@
           />
         </a-form-item>
 
-        <a-form-item v-if="tableType === 'alarmRetention'" label="告警类型">
+        <a-form-item v-if="tableType === 'alarm'" label="告警类型">
           <a-select
             v-model:value="searchState.eventType"
             placeholder="请选择"
-            style="width: 120px"
+            style="width: 150px"
             :options="alarmEventTypeOptions"
             @change="searchHandler"
           />
         </a-form-item>
 
-        <a-form-item v-if="tableType === 'generalRetention'" label="滞留类型">
-          <a-select
-            v-model:value="searchState.type"
-            placeholder="请选择"
-            style="width: 120px"
-            :options="stayEventTypeOptions"
-            @change="searchHandler"
-          />
-        </a-form-item>
-
         <a-form-item>
           <a-space>
             <a-button type="primary" @click="searchHandler"> 搜索 </a-button>
@@ -58,26 +47,30 @@
     <div class="tableCard">
       <a-table :columns="columns" :data-source="tableList" :loading="loading" :pagination="false">
         <template #bodyCell="{ column, record }">
-          <!-- 跌倒、异常统计 姿势  -->
           <template v-if="column.key === 'pose'">
             {{ record.poseName }}
           </template>
-          <!-- 跌倒、异常统计 是否处理  -->
           <template v-if="column.key === 'isHandle'">
             <a-tag v-if="record.isHandle === 0" :bordered="false" color="red">未处理</a-tag>
             <a-tag v-if="record.isHandle === 1" :bordered="false" color="green">已处理</a-tag>
           </template>
-          <!-- 异常滞留 告警类型 -->
           <template v-if="column.key === 'eventType'">
             {{ record.eventTypeName }}
           </template>
-          <!-- 一般滞留 滞留类型 -->
-          <template v-if="column.key === 'type'">
-            {{ record.typeName }}
-          </template>
-          <!-- 一般滞留 停留时间 -->
-          <template v-if="column.key === 'stayTime'">
-            {{ formatSeconds(record.stayTime) }}
+          <template v-if="column.key === 'info'">
+            <div v-if="record.info?.start_time">开始时间:{{ record.info?.start_time }}</div>
+            <div v-if="record.info?.end_time">结束时间:{{ record.info?.end_time }}</div>
+            <div v-if="record.info?.stay_time">停留时长:{{ record.info?.stay_time }}(秒)</div>
+            <div v-if="record.info?.count">次数{{ record.info?.count }}</div>
+            <template v-if="record.info?.event_list">
+              事件列表:
+              <div v-for="(event, index) in record.info?.event_list" :key="index">
+                <div>开始时间{{ event.start_time }}</div>
+                <div>结束时间{{ event.end_time }}</div>
+                <div>停留时长{{ event.stay_time }}(秒)</div>
+                <div>消失时长{{ event.absence_time }}(秒)</div>
+              </div>
+            </template>
           </template>
         </template>
       </a-table>
@@ -97,16 +90,12 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
 import { useSearch } from '@/hooks/useSearch'
-import { fallColumns, generalRetentionColumns, alarmRetentionColumns } from './const'
+import { fallColumns, alarmColumns } from './const'
 import * as statsApi from '@/api/stats'
-import type {
-  StatsFallQueryDataRow,
-  StatsAlarmRetentionQueryDataRow,
-  StatsGeneralRetentionQueryDataRows,
-} from '@/api/stats/types'
-import { useDict } from '@/hooks/useDict'
+import type { StatsFallQueryDataRow, StatsAlarmQueryDataRow } from '@/api/stats/types'
+import { useDict, type DictItem } from '@/hooks/useDict'
 import { useDictName } from '@/hooks/useDictName'
-import { formatSeconds } from '@/utils'
+import * as alarmApi from '@/api/alarm'
 
 defineOptions({
   name: 'DeviceStatsDrawer',
@@ -130,27 +119,12 @@ const props = withDefaults(defineProps<Props>(), {
   width: 900,
 })
 
-watch(
-  () => props.open,
-  (newVal) => {
-    if (newVal) {
-      console.log('🌸🌸 DeviceStatsDrawer PROPS 🌸🌸', props)
-      fetchList()
-    }
-  },
-  {
-    immediate: true,
-  }
-)
-
 // 表格统计类型展示 跌倒、一般滞留、异常滞留
-const tableType = ref<'fall' | 'generalRetention' | 'alarmRetention'>('fall')
+const tableType = ref<'fall' | 'alarm'>('fall')
 
 const columns = computed(() => {
-  if (tableType.value === 'generalRetention') {
-    return generalRetentionColumns
-  } else if (tableType.value === 'alarmRetention') {
-    return alarmRetentionColumns
+  if (tableType.value === 'alarm') {
+    return alarmColumns
   } else {
     return fallColumns
   }
@@ -192,15 +166,29 @@ const close = async () => {
 
 // 获取字典数据
 const { dictList: personPoseList, fetchDict: fetchPersonPose } = useDict('person_pose') // 姿势字典
-const { dictList: alarmEventTypeList, fetchDict: fetchAlarmEventType } = useDict('alarm_event_type') // 异常告警字典
-const { dictList: stayEventTypeList, fetchDict: fetchStayEventType } = useDict('stay_event_type') // 停留类型字典
 fetchPersonPose()
-fetchAlarmEventType()
-fetchStayEventType()
 
+const alarmEventTypeList = ref<DictItem[]>([])
 const { dictNameMap: personPoseName } = useDictName(personPoseList)
 const { dictNameMap: alarmEventTypeName } = useDictName(alarmEventTypeList)
-const { dictNameMap: stayEventTypeName } = useDictName(stayEventTypeList)
+
+const fetchEventTypeList = async () => {
+  try {
+    const res = await alarmApi.getAlarmEventTypeList()
+    console.log('获取事件类型下拉列表成功✅', res)
+    const data = res.data
+    alarmEventTypeList.value =
+      (Array.isArray(data) &&
+        data.map((item) => ({
+          label: item.eventDesc,
+          value: item.eventVal,
+        }))) ||
+      []
+  } catch (err) {
+    console.log('获取事件类型下拉列表失败❌', err)
+  }
+}
+fetchEventTypeList()
 
 // 异常滞留 事件类型
 const alarmEventTypeOptions = computed(() => [
@@ -213,28 +201,14 @@ const alarmEventTypeOptions = computed(() => [
     value: item.value,
   })),
 ])
-// 一般滞留 滞留类型
-const stayEventTypeOptions = computed(() => [
-  {
-    label: '全部',
-    value: null,
-  },
-  ...stayEventTypeList.value.map((item) => ({
-    label: item.label,
-    value: item.value,
-  })),
-])
 
-const tableList = ref<
-  (StatsFallQueryDataRow | StatsGeneralRetentionQueryDataRows | StatsAlarmRetentionQueryDataRow)[]
->([])
+const tableList = ref<(StatsFallQueryDataRow | StatsAlarmQueryDataRow)[]>([])
 const tableTotal = ref<number>(0)
 const current = ref<number>(1)
 const pageSize = ref<number>(10)
 
 const fallList = ref<StatsFallQueryDataRow[]>([])
-const generalRetentionList = ref<StatsGeneralRetentionQueryDataRows[]>([])
-const alarmRetentionList = ref<StatsAlarmRetentionQueryDataRow[]>([])
+const alarmList = ref<StatsAlarmQueryDataRow[]>([])
 
 // 搜索
 const searchHandler = async () => {
@@ -270,7 +244,7 @@ const fetchFallList = async () => {
     const { rows, total } = res.data
     fallList.value = rows as StatsFallQueryDataRow[]
     fallList.value.forEach((item) => {
-      item.poseName = personPoseName(String(item.pose))
+      item.poseName = item.pose !== null ? personPoseName(String(item.pose)) : ''
     })
     tableList.value = fallList.value
     tableTotal.value = Number(total)
@@ -281,61 +255,36 @@ const fetchFallList = async () => {
   }
 }
 
-// 获取一般滞留数据
-const fetchGeneralRetentionList = async () => {
-  console.log('🚀🚀🚀fetchGeneralRetentionList')
+// 获取告警统计数据
+const fetchAlarmList = async () => {
+  console.log('🚀🚀🚀fetchAlarmList')
   try {
     loading.value = true
-    const res = await statsApi.statsGeneralRetentionQuery({
+    const res = await statsApi.statsAlarmQuery({
       pageNo: current.value,
       pageSize: pageSize.value,
-      devId: props.devId,
-      createTimeStart: searchState.createTimeStart,
-      createTimeEnd: searchState.createTimeEnd,
-      type: searchState.type as ID,
-    })
-    console.log('✅ 获取一般滞留统计数据成功', res)
-    const { rows, total } = res.data
-    generalRetentionList.value = rows as StatsGeneralRetentionQueryDataRows[]
-    generalRetentionList.value.forEach((item) => {
-      console.log('item.pose', item)
-      item.typeName = stayEventTypeName(String(item.type))
-    })
-    tableList.value = generalRetentionList.value
-    tableTotal.value = Number(total)
-    loading.value = false
-  } catch (error) {
-    console.error('❌ 获取一般滞留统计数据失败', error)
-    loading.value = false
-  }
-}
-
-// 获取异常滞留数据
-const fetchAlarmRetentionList = async () => {
-  console.log('🚀🚀🚀fetchAlarmRetentionList')
-  try {
-    loading.value = true
-    const res = await statsApi.statsAlarmRetentionQuery({
-      pageNo: current.value,
-      pageSize: pageSize.value,
-      devId: props.devId,
+      clientId: props.devId,
       createTimeStart: searchState.createTimeStart,
       createTimeEnd: searchState.createTimeEnd,
       eventType: searchState.eventType as ID,
     })
-    console.log('✅ 获取异常滞留统计数据成功', res)
+    console.log('✅ 获取告警统计数据成功', res)
     const { rows, total } = res.data
-    alarmRetentionList.value = rows as StatsAlarmRetentionQueryDataRow[]
-    alarmRetentionList.value.forEach((item) => {
-      console.log('item.pose', item)
-      item.poseName = personPoseName(String(item.pose))
-      item.eventTypeName = alarmEventTypeName(String(item.eventType))
+    alarmList.value = rows as StatsAlarmQueryDataRow[]
+    alarmList.value.forEach((item) => {
+      item.eventTypeName = alarmEventTypeName(item.eventType)
+      try {
+        item.info = item.info && JSON.parse(item.info)
+      } catch (error) {
+        item.info = ''
+        console.log('❌ 解析info失败', error)
+      }
     })
-    tableList.value = alarmRetentionList.value
+    tableList.value = alarmList.value
     tableTotal.value = Number(total)
     loading.value = false
   } catch (error) {
-    console.error('❌ 获取异常滞留统计数据失败', error)
+    console.error('❌ 获取告警统计数据失败', error)
     loading.value = false
   }
 }
@@ -344,11 +293,8 @@ const fetchList = () => {
   if (tableType.value === 'fall') {
     fetchFallList()
   }
-  if (tableType.value === 'generalRetention') {
-    fetchGeneralRetentionList()
-  }
-  if (tableType.value === 'alarmRetention') {
-    fetchAlarmRetentionList()
+  if (tableType.value === 'alarm') {
+    fetchAlarmList()
   }
 }
 
@@ -359,6 +305,19 @@ watch(
   },
   { immediate: true }
 )
+
+watch(
+  () => props.open,
+  (newVal) => {
+    if (newVal) {
+      console.log('🌸🌸 DeviceStatsDrawer PROPS 🌸🌸', props)
+      fetchList()
+    }
+  },
+  {
+    immediate: true,
+  }
+)
 </script>
 
 <style scoped lang="less">

+ 239 - 15
src/views/device/detail/index.vue

@@ -211,6 +211,7 @@
           <info-item label="检测区域">
             <template v-if="detailState.length || detailState.width">
               {{ detailState.length || 0 }} x {{ detailState.width || 0 }} cm
+              {{ [detailState.xxStart, detailState.xxEnd, detailState.yyStart, detailState.yyEnd] }}
             </template>
           </info-item>
           <info-item label="安装位置">
@@ -226,22 +227,40 @@
       </info-card>
 
       <info-card>
-        <info-item-group title="告警计划">
+        <info-item-group title="告警计划" class="alarmPlanGroup">
           <template #extra>
             <a-space>
-              <a-button type="primary" size="small" @click="alarmPlanVisible = true">
-                新增计划
-              </a-button>
+              <a-button type="primary" size="small" @click="addPlanHandler"> 新增计划 </a-button>
             </a-space>
           </template>
 
-          <info-item label="生效计划"> <a-badge status="success" /> 计划A </info-item>
-          <info-item label="全部计划">
-            <div><a-badge status="default" />计划A</div>
-            <div><a-badge status="default" />计划B</div>
-            <div><a-badge status="default" />计划C</div>
-            <div><a-badge status="default" />计划D</div>
-          </info-item>
+          <ScrollContainer style="max-height: 450px">
+            <a-spin :spinning="alarmPlanLoading">
+              <div v-if="alarmPlans && alarmPlans.length" class="alarmPlan">
+                <div class="alarmPlan-item" v-for="plan in alarmPlans" :key="plan.id">
+                  <div class="alarmPlan-item-label"
+                    ><a-badge :status="plan.enable ? 'success' : 'error'"
+                  /></div>
+                  <div class="alarmPlan-item-contant" :title="plan.name">{{ plan.name }}</div>
+                  <div class="alarmPlan-item-action">
+                    <a-space>
+                      <a-switch
+                        :checked="plan.enable"
+                        size="small"
+                        :loading="plan.loading"
+                        @click="swtichAlarmItem(plan.id, plan.enable, plan)"
+                      />
+                      <EditOutlined @click="editAlarmItem(plan.data)" />
+                      <DeleteOutlined @click="deleteAlarmItem(plan.id)" />
+                    </a-space>
+                  </div>
+                </div>
+              </div>
+              <div v-else class="alarmPlan-empty">
+                <a-empty :image="simpleImage" />
+              </div>
+            </a-spin>
+          </ScrollContainer>
         </info-item-group>
       </info-card>
 
@@ -252,6 +271,10 @@
           clientId: (detailState.clientId as string) || '',
           length: (detailState.length as number) || 0,
           width: (detailState.width as number) || 0,
+          xStart: (detailState.xxStart as number) || 0,
+          xEnd: (detailState.xxEnd as number) || 0,
+          yStart: (detailState.yyStart as number) || 0,
+          yEnd: (detailState.yyEnd as number) || 0,
         }"
         :mode="configDrawerMode"
         :room-id="deviceRoomId"
@@ -266,7 +289,23 @@
         :title="`${detailState.devName || ''} 统计信息`"
       ></deviceStatsDrawer>
 
-      <alarmPlanModal v-model:open="alarmPlanVisible"></alarmPlanModal>
+      <alarmPlanModal
+        v-model:open="alarmPlanVisible"
+        :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>
   </a-spin>
 </template>
@@ -291,6 +330,11 @@ import { formatDateTime } from '@/utils'
 import { FullscreenOutlined } from '@ant-design/icons-vue'
 import FullViewModal from './components/fullViewModal/index.vue'
 import alarmPlanModal from './components/alarmPlanModal/index.vue'
+import { EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
+import * as alarmApi from '@/api/alarm'
+import { Empty } from 'ant-design-vue'
+const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE
+import { getOriginPosition } from '@/utils'
 
 defineOptions({
   name: 'DeviceDetail',
@@ -336,8 +380,9 @@ const fetchRoomLayout = async () => {
     if (!res) return
     const { furnitures, roomId, subRegions } = res.data
     if (furnitures) {
-      furnitureItems.value = furnitures!.map((item) => {
-        return {
+      // 添加接口返回的家具数据
+      furnitures!.forEach((item) => {
+        furnitureItems.value.push({
           ...item,
           width: item.width || 45,
           length: item.length || 45,
@@ -346,7 +391,7 @@ const fetchRoomLayout = async () => {
           rotate: item.rotate || 0,
           x: item.x || 0,
           y: item.y || 0,
-        }
+        })
       })
     }
     deviceRoomId.value = roomId || ''
@@ -411,6 +456,31 @@ const fetchDeviceDetail = async () => {
     console.log('✅获取到设备详情', res)
     detailState.value = res.data
     spinning.value = false
+
+    // 获取雷达图标尺寸
+    const { radarX, radarY, radarWidth, radarHeight, originOffsetX, originOffsetY } =
+      getOriginPosition(
+        [
+          res.data.xxStart as number,
+          res.data.xxEnd as number,
+          res.data.yyStart as number,
+          res.data.yyEnd as number,
+        ],
+        [0, 0]
+      )
+
+    // 添加雷达图标
+    furnitureItems.value.unshift({
+      name: '雷达',
+      type: 'radar',
+      width: radarWidth,
+      length: radarHeight,
+      top: radarY,
+      left: radarX,
+      x: originOffsetX,
+      y: originOffsetY,
+      rotate: 0,
+    })
   } catch (error) {
     console.error('❌获取设备详情失败', error)
     spinning.value = false
@@ -585,7 +655,105 @@ onUnmounted(() => {
 })
 
 const openFullView = ref(false) // 全屏展示点位图
+
 const alarmPlanVisible = ref(false) // 告警计划弹窗
+const alarmPlanId = ref<number | null>(null) // 当前编辑的告警计划id
+const alarmPlanLoading = ref(false) // 告警计划加载中
+const alarmPlans = ref<
+  { id: number; name: string; enable: boolean; loading: boolean; data: object }[]
+>([])
+// 获取告警计划列表
+const fetchAlarmPlanList = async () => {
+  try {
+    alarmPlanLoading.value = true
+    await alarmApi
+      .getAlarmPlanList({
+        clientId: clientId.value,
+      })
+      .then((res) => {
+        console.log('获取告警计划列表成功✅', res)
+        const data = res.data
+        if (Array.isArray(data) && data.length) {
+          alarmPlans.value = data.map((item) => ({
+            id: item.id || '',
+            name: item.name || '',
+            enable: item.enable === 1,
+            loading: false,
+            data: item,
+          }))
+        } else {
+          alarmPlans.value = []
+        }
+      })
+      .catch((err) => {
+        console.log('获取告警计划列表失败❌', err)
+      })
+      .finally(() => {
+        alarmPlanLoading.value = false
+      })
+  } catch (err) {
+    console.log('获取告警计划列表失败❌', err)
+  }
+}
+fetchAlarmPlanList()
+
+interface AlarmPlanItem {
+  id?: number
+  loading?: boolean
+  [key: string]: unknown
+}
+const alarmPlanData = ref<AlarmPlanItem | undefined>(undefined)
+// 编辑告警计划
+const editAlarmItem = async (item: AlarmPlanItem) => {
+  console.log('editAlarmItem', item)
+  alarmPlanVisible.value = true
+  alarmPlanData.value = item
+  alarmPlanId.value = item?.id ?? null
+}
+
+// 添加告警计划
+const addPlanHandler = () => {
+  alarmPlanVisible.value = true
+  alarmPlanData.value = undefined
+  alarmPlanId.value = null
+}
+
+// 删除告警计划
+const deleteAlarmItem = async (id: number) => {
+  console.log('deleteAlarmItem', id)
+  try {
+    alarmApi.deleteAlarmPlan({ id }).then(() => {
+      message.success('删除成功')
+      fetchAlarmPlanList()
+    })
+  } catch (err) {
+    console.log('删除告警计划失败❌', err)
+    message.error('删除失败')
+  }
+}
+
+// 启用/禁用告警计划
+const swtichAlarmItem = async (id: number, swtich: boolean, item: AlarmPlanItem) => {
+  console.log('swtichAlarmItem', id, swtich, item)
+  try {
+    item.loading = true
+    alarmApi
+      .enableAlarmPlan({ id, enable: Number(!swtich) as 0 | 1 })
+      .then(() => {
+        message.success('变更成功')
+        item.loading = false
+        fetchAlarmPlanList()
+      })
+      .catch((err) => {
+        console.log('启用/禁用告警计划失败❌', err)
+        item.loading = false
+        message.error('操作失败')
+      })
+  } catch (err) {
+    console.log('启用/禁用告警计划失败❌', err)
+    message.error('变更失败')
+  }
+}
 </script>
 
 <style scoped lang="less">
@@ -670,4 +838,60 @@ const alarmPlanVisible = ref(false) // 告警计划弹窗
   flex-basis: 350px;
   position: relative;
 }
+
+.alarmPlanGroup {
+  :deep(.info-item-group-content) {
+    padding: 12px 0 !important;
+    overflow-y: auto;
+    max-height: 500px;
+  }
+  .alarmPlan {
+    display: flex;
+    flex-direction: column;
+    flex-wrap: wrap;
+    width: 100%;
+    color: #555;
+    &-empty {
+      padding: 50px 0;
+    }
+
+    .alarmPlan-item {
+      display: flex;
+      align-items: center;
+      flex: 1;
+      padding: 8px 12px;
+      &:hover {
+        border-radius: 8px;
+        background: #f5f5f5;
+      }
+    }
+    .alarmPlan-item-label {
+      text-align: right;
+      flex-shrink: 0;
+    }
+    .alarmPlan-item-contant {
+      font-size: 14px;
+      flex-grow: 1;
+      max-width: 250px;
+      word-break: break-all;
+      line-height: 1.5;
+      padding: 0 12px;
+      // 超出一行显示省略号
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      cursor: default;
+    }
+    .alarmPlan-item-action {
+      flex-shrink: 0;
+      color: #888;
+      :deep(.ant-space) {
+        cursor: pointer;
+        .ant-space-item:hover {
+          color: #40a9ff;
+        }
+      }
+    }
+  }
+}
 </style>

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