Browse Source

feat(设备详情): 新增告警计划管理功能

- 添加告警计划列表展示、新增、编辑、删除及启用/禁用功能
- 实现告警计划弹窗表单及参数联动逻辑
- 新增ScrollContainer滚动容器组件
- 重构Switch类型为SwitchType并更新相关引用
- 优化告警计划API类型定义及接口
- 移除请求拦截器中get请求的时间戳参数
- 添加ant-design-vue Empty组件类型声明
liujia 1 month ago
parent
commit
81e759b

+ 2 - 0
components.d.ts

@@ -22,6 +22,7 @@ declare module 'vue' {
     ADivider: typeof import('ant-design-vue/es')['Divider']
     ADrawer: typeof import('ant-design-vue/es')['Drawer']
     ADropdown: typeof import('ant-design-vue/es')['Dropdown']
+    AEmpty: typeof import('ant-design-vue/es')['Empty']
     AForm: typeof import('ant-design-vue/es')['Form']
     AFormItem: typeof import('ant-design-vue/es')['FormItem']
     AFormItemRest: typeof import('ant-design-vue/es')['FormItemRest']
@@ -69,5 +70,6 @@ declare module 'vue' {
     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']
   }
 }

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

@@ -9,3 +9,38 @@ export const saveAlarmPlan = (
 ): 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)
+}

+ 65 - 45
src/api/alarm/types.ts

@@ -1,62 +1,53 @@
 /**
  * 告警计划事件类型
- * @use 以下3个的param参数传 {}
- * @enum {string} stay_detection 停留事件
- * @enum {string} retention_detection 异常滞留
- * @enum {string} toileting_detection 如厕事件
- *
- * @use 以下3个的param参数传 { "start_time": "22:00", "end_time": "6:00" }
- * @enum {string} toileting_frequency 如厕频次统计
- * @enum {string} night_toileting_frequency 夜间如厕频次统计
- * @enum {string} bathroom_stay_frequency 卫生间频次统计
- *
- * @use 以下2个的param参数传 { "start_time": "22:00", "end_time": "6:00", "count": 3 }
- * @enum {string} toilet_frequency_abnormal 如厕频次异常
- * @enum {string} night_toileting_frequency_abnormal 起夜异常
- *
- * @use 以下1个的param参数传 { "time_threshold": 300 }
- * @enum {string} target_absence 异常消失
+ * @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 异常消失
  */
-type EventParamMap = {
-  stay_detection: object
-  retention_detection: object
-  toileting_detection: object
-  toileting_frequency: { start_time: string; end_time: string }
-  night_toileting_frequency: { start_time: string; end_time: string }
-  bathroom_stay_frequency: { start_time: string; end_time: string }
-  toilet_frequency_abnormal: { start_time: string; end_time: string; count: number }
-  night_toileting_frequency_abnormal: { start_time: string; end_time: string; count: number }
-  target_absence: { time_threshold: number }
+export type EventValParam = {
+  start_time?: string // 统计开始时间
+  end_time?: string // 统计结束时间
+  count?: number // 异常阈值
+  time_threshold?: number // 异常消失时间阈值(单位:秒)
 }
 
-export type EventVal = keyof EventParamMap
-export type EventValParam<T extends EventVal = EventVal> = EventParamMap[T]
-
-type TimeRange = { start_time: string; end_time: string }
 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: TimeRange[] // 	每日生效时间[{"start_time": "00:00","end_time": "12:00"},{"start_time": "18:00","end_time": "23:59"}, ...]
+  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
- * @param clientId 设备ID
- * @param name 告警计划名称
- * @param region 检测区域 [left, top, width, height]
- * @param eventVal 告警事件值
- * @param param 告警参数 需要根据 eventVal 传对应的参数
- * @param alarmTimePlan 告警时间计划
- * @param enable 是否启用(number) 1: 启用 0: 禁用
  */
 export interface AlarmPlanParams {
-  clientId: string // 设备ID
+  alarmPlanId?: number // 告警计划ID
+  uuid?: string // 告警计划UUID
   name: string // 告警计划名称
-  region: string[] // 检测区域 [left, top, width, height]
-  eventVal: EventVal | null // 告警事件值
-  param: EventValParam | object // 告警参数 需要根据 eventVal 传对应的参数
+  clientId: string // 设备ID
+  region: string // 检测区域 '[left, top, width, height]'
+  eventVal: number // 告警事件值
+  param: string // 告警参数 需要根据 eventVal 传对应的参数
   alarmTimePlan: AlarmTimePlan // 告警时间计划
   enable: 0 | 1 // 是否启用(number) 1: 启用 0: 禁用
 }
@@ -65,9 +56,38 @@ export interface AlarmPlanParams {
  * 保存告警计划响应数据
  */
 export interface AlarmPlanResponseData extends Omit<AlarmPlanParams, 'eventVal'> {
-  eventVal: 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 // 创建时间

+ 4 - 4
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-已处理
 }
 
 /**
@@ -89,7 +89,7 @@ export interface StatsAlarmRetentionQueryDataRow {
   stayTimeId: string // 事件表主键id
   eventType: string // 告警类型
   eventTypeName: string // 告警类型名称
-  isHandle: Switch // 是否处理:0-未处理,1-已处理
+  isHandle: SwitchType // 是否处理:0-未处理,1-已处理
 }
 
 /**
@@ -127,7 +127,7 @@ export interface StatsGeneralRetentionQueryDataRows {
   updateId: ID // 修改人
   createTime: string // 创建时间
   updateTime: string // 更新时间
-  isDeleted: Switch // 	删除标记:0-未删除,1-已删除
+  isDeleted: SwitchType // 	删除标记:0-未删除,1-已删除
   remark: string // 备注
   stayTimeId: ID // stay_time表主键id
   devId: ID // 设备表主键id

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

+ 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

+ 439 - 122
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,110 @@
           />
         </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%"
+          />
+        </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%"
+                  />
+                </a-form-item>
+              </div>
+            </div>
+          </a-form-item-rest>
         </a-form-item>
 
         <a-form-item
@@ -55,30 +148,6 @@
           />
         </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="month">按月</a-radio>
-          </a-radio-group>
-        </a-form-item>
-
-        <a-form-item label="生效范围" name="firmwareVersion">
-          <a-form-item-rest>
-            <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 label="生效时段">
           <div style="display: flex; align-items: center; gap: 8px">
             <a-time-range-picker
@@ -89,7 +158,9 @@
             <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: 14px"
+            <span
+              v-if="formState.effectTimeFrames && !formState.effectTimeFrames.length"
+              style="color: #aaa; font-size: 14px"
               >⚠️暂无生效时段</span
             >
             <a-space wrap v-else>
@@ -104,8 +175,34 @@
             </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 label="检测区域" name="region">
+          检测区域选择组件 {{ formState.region }}
+        </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>
 
@@ -120,10 +217,9 @@
 </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 alarmApi from '@/api/alarm'
-import type { EventVal, EventValParam } from '@/api/alarm/types'
 
 defineOptions({
   name: 'AlarmPlanModal',
@@ -131,9 +227,43 @@ 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 // 编辑数据
 }
 const emit = defineEmits<{
   (e: 'update:open', value: boolean): void
@@ -143,68 +273,57 @@ const emit = defineEmits<{
 const props = withDefaults(defineProps<Props>(), {
   open: false,
   title: '告警计划',
+  clientId: '',
+  alarmPlanId: null,
+  data: undefined,
+})
+
+const modelTitle = computed(() => {
+  return props.alarmPlanId ? '编辑告警计划' : '新增告警计划'
 })
 
 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: EventVal | 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: [],
+  region: [-200, 200, 400, 400],
   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 plainOptions = ref<(number | string)[]>(weekOptions)
 const checkState = reactive({
   indeterminate: true,
   checkAll: false,
@@ -217,6 +336,27 @@ 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) => {
@@ -228,6 +368,102 @@ 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 ?? '[]'),
+    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
+    }
+  },
+  { immediate: true }
+)
+
 // 生效方式变化 周week、月month
 const effectTypeChange = (e: Event) => {
   const value = (e.target as HTMLInputElement).value
@@ -236,9 +472,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 =
@@ -266,21 +502,48 @@ const deleteEffectTimeItem = (e: Event, index: number) => {
   formState.effectTimeFrames.splice(index, 1)
 }
 
+// 关闭弹窗
 const cancel = () => {
   formRef?.value?.resetFields()
   emit('update:open', false)
+  // 重置表单
+  formState.planName = ''
+  formState.region = []
+  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 weekdaysReverseMap: Record<string, number> = {
-  周一: 1,
-  周二: 2,
-  周三: 3,
-  周四: 4,
-  周五: 5,
-  周六: 6,
-  周日: 7,
+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()
 
 const submitLoading = ref(false)
 // 确定
@@ -289,55 +552,79 @@ const submit = () => {
     ?.validate()
     .then(() => {
       console.log('校验通过', formState)
-      let paramData: EventValParam = {}
-      if (
-        ['stay_detection', 'retention_detection', 'toileting_detection'].includes(
-          formState.eventType as string
-        )
-      ) {
-        console.log('参数传 {}')
+      let paramData = {}
+      if ([1, 2, 3].includes(formState.eventType as number)) {
         paramData = {}
-      } else if (
-        ['toileting_frequency', 'night_toileting_frequency', 'bathroom_stay_frequency'].includes(
-          formState.eventType as string
-        )
-      ) {
-        console.log('参数传 { "start_time": "22:00", "end_time": "6:00" }')
-        paramData = { start_time: '22:00', end_time: '6:00' }
-      } else if (
-        ['toilet_frequency_abnormal', 'night_toileting_frequency_abnormal'].includes(
-          formState.eventType as string
-        )
-      ) {
-        console.log('参数传 { "start_time": "22:00", "end_time": "6:00", "count": 3 }')
-        paramData = { start_time: '22:00', end_time: '6:00', count: 3 }
-      } else if (['target_absence'].includes(formState.eventType as string)) {
-        console.log('参数传 { "time_threshold": 300 }')
-        paramData = { time_threshold: 300 }
+        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
+            : Number(formState.timeThreshold),
+        }
+        console.log('🔥paramData🔥', paramData)
       }
 
       const params = {
-        clientId: '', // 设备ID
+        alarmPlanId: props.alarmPlanId || undefined, // 告警计划ID 编辑时传入
+        clientId: props.clientId, // 设备ID
         name: formState.planName, // 计划名称
-        region: formState.region, // 检测区域 [left, top, width, height]
-        eventVal: formState.eventType, // 事件类型 与 param 有联动关系
-        param: paramData, // 事件参数 与 eventVal 有联动关系
-        enable: Number(formState.isEnable) as 0 | 1, // 是否启用 0否 1是
+        remark: formState.remark || '', // 备注
+        thresholdTime: Number(formState.thresholdTime) || 300, // 触发阈值
+        mergeTime: Number(formState.mergeTime) || 30, // 归并时间
+        eventVal: formState.eventType as number, // 事件类型 与 param 有联动关系
+        param: JSON.stringify(paramData), // 事件参数 与 eventVal 有联动关系
+        region: JSON.stringify(formState.region), // 检测区域
+        enable: Number(formState.enable) as 0 | 1, // 是否启用 0否 1是
+
+        // 生效方式
         alarmTimePlan: {
           startDate: formState.planTime[0], // 计划开始时间
           stopDate: formState.planTime[1], // 计划结束时间
-          monthDays: formState.effectType === 'month' ? formState.effectTimeRanges : [], // 按月生效范围 1-31
-          weekdays:
+          // 生效时段
+          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: string) => String(weekdaysReverseMap[item]))
-              : [], // 按周生效范围 1-7
-          timeRange: formState.effectTimeFrames.map((item) => ({
-            start_time: item.startTime, // 生效开始时间
-            end_time: item.endTime, // 生效结束时间
-          })),
+              ? 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 (formState.region.length !== 4) {
+      //   message.warn('请选择检测区域')
+      //   return
+      // }
+
       submitLoading.value = true
       alarmApi
         .saveAlarmPlan(params)
@@ -377,4 +664,34 @@ 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;
+    }
+  }
+}
 </style>

+ 194 - 12
src/views/device/detail/index.vue

@@ -226,22 +226,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>
 
@@ -266,7 +284,13 @@
         :title="`${detailState.devName || ''} 统计信息`"
       ></deviceStatsDrawer>
 
-      <alarmPlanModal v-model:open="alarmPlanVisible"></alarmPlanModal>
+      <alarmPlanModal
+        v-model:open="alarmPlanVisible"
+        :client-id="clientId"
+        :alarm-plan-id="alarmPlanId"
+        :data="alarmPlanData"
+        @success="fetchAlarmPlanList"
+      ></alarmPlanModal>
     </div>
   </a-spin>
 </template>
@@ -291,6 +315,10 @@ 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
 
 defineOptions({
   name: 'DeviceDetail',
@@ -585,7 +613,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 +796,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>