Selaa lähdekoodia

feat(设备详情): 新增告警计划功能并优化信息展示布局

- 添加告警计划弹窗组件,支持计划配置和时间段管理
- 引入infoItemGroup组件重构信息卡片布局,分组展示设备信息
- 调整infoCard和infoItem组件样式,优化内容显示效果
- 新增ABadge、ACheckbox等Ant Design组件类型声明
- 仅在床位区域显示呼吸检测配置项
liujia 1 kuukausi sitten
vanhempi
commit
1d0dc75

+ 5 - 0
components.d.ts

@@ -10,8 +10,11 @@ declare module 'vue' {
   export interface GlobalComponents {
     AAlert: typeof import('ant-design-vue/es')['Alert']
     AAvatar: typeof import('ant-design-vue/es')['Avatar']
+    ABadge: typeof import('ant-design-vue/es')['Badge']
     AButton: typeof import('ant-design-vue/es')['Button']
     ACascader: typeof import('ant-design-vue/es')['Cascader']
+    ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
+    ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']
     ACollapse: typeof import('ant-design-vue/es')['Collapse']
     ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
     AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
@@ -33,6 +36,7 @@ declare module 'vue' {
     APageHeader: typeof import('ant-design-vue/es')['PageHeader']
     APagination: typeof import('ant-design-vue/es')['Pagination']
     APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
+    ARadio: typeof import('ant-design-vue/es')['Radio']
     ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
     ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
     ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
@@ -44,6 +48,7 @@ declare module 'vue' {
     ASwitch: typeof import('ant-design-vue/es')['Switch']
     ATable: typeof import('ant-design-vue/es')['Table']
     ATag: typeof import('ant-design-vue/es')['Tag']
+    ATimeRangePicker: typeof import('ant-design-vue/es')['TimeRangePicker']
     ATooltip: typeof import('ant-design-vue/es')['Tooltip']
     ATree: typeof import('ant-design-vue/es')['Tree']
     AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']

+ 323 - 0
src/views/device/detail/components/alarmPlanModal/index.vue

@@ -0,0 +1,323 @@
+<template>
+  <div ref="mod">
+    <a-modal
+      :get-container="() => $refs.mod"
+      :open="props.open"
+      :title="title"
+      :mask-closable="false"
+      width="600px"
+      @cancel="cancel"
+      :footer="null"
+    >
+      <a-form
+        ref="formRef"
+        :model="formState"
+        :label-col="{ style: { width: '80px' } }"
+        hideRequiredMark
+      >
+        <a-form-item
+          label="计划名称"
+          name="planName"
+          :rules="[{ required: true, message: '请输入计划名称' }]"
+        >
+          <a-input
+            v-model:value.trim="formState.planName"
+            placeholder="请输入计划名称"
+            :maxlength="20"
+            show-count
+            allow-clear
+          />
+        </a-form-item>
+
+        <a-form-item label="检测区域" name="region"> 检测区域选择组件 </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-form-item>
+
+        <a-form-item
+          label="计划时间"
+          name="planTime"
+          :rules="[{ type: 'array' as const, required: true, message: '请选择计划时间' }]"
+        >
+          <a-range-picker
+            v-model:value="formState.planTime"
+            style="width: 100%"
+            show-time
+            valueFormat="YYYY-MM-DD HH:mm:ss"
+          />
+        </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"
+              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
+            >
+            <a-space wrap v-else>
+              <a-tag
+                v-for="(item, index) in formState.effectTimeFrames"
+                :key="index"
+                closable
+                style="font-size: 14px; padding: 4px 10px"
+                @close="deleteEffectTimeItem($event, index)"
+                >{{ item.startTime }} - {{ item.endTime }}</a-tag
+              >
+            </a-space>
+          </div>
+        </a-form-item>
+        <a-form-item label="是否启用">
+          <a-switch v-model:checked="formState.isEnable" />
+        </a-form-item>
+      </a-form>
+
+      <div class="footer">
+        <a-space>
+          <a-button @click="cancel">取消</a-button>
+          <a-button type="primary" :loading="submitLoading" @click="submit">保存</a-button>
+        </a-space>
+      </div>
+    </a-modal>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, watch } 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'
+
+defineOptions({
+  name: 'AlarmPlanModal',
+})
+
+const formRef = ref<FormInstance>()
+
+type Props = {
+  open: boolean
+  title?: string
+}
+const emit = defineEmits<{
+  (e: 'update:open', value: boolean): void
+  (e: 'success', value: void): void
+}>()
+
+const props = withDefaults(defineProps<Props>(), {
+  open: false,
+  title: '告警计划',
+})
+
+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',
+]
+
+type FormState = {
+  planName: string // 计划名称
+  region: string[] // 检测区域 [left, top, width, height]
+  eventType: string | null // 事件类型
+  planTime: string[] // 计划时间
+  effectType: 'week' | 'month' // 生效方式 周week、月month
+  effectTimeRanges: string[] // 生效范围 周 1-7、月 1-31
+  effectTimeFrame: string[] // 生效时段 单条 00:00:00 - 23:59:59
+  effectTimeFrames: { startTime: string; endTime: string }[] // 生效时段 多条
+  isEnable: boolean // 是否启用
+}
+
+const formState = reactive<FormState>({
+  planName: '',
+  region: [],
+  eventType: '',
+  planTime: [],
+  effectType: 'week',
+  effectTimeRanges: weekOptions,
+  effectTimeFrame: [],
+  effectTimeFrames: [],
+  isEnable: true,
+})
+
+const plainOptions = ref<string[]>(weekOptions)
+const checkState = reactive({
+  indeterminate: true,
+  checkAll: false,
+})
+
+const onCheckAllChange = (e: Event) => {
+  const checked = (e.target as HTMLInputElement).checked
+  checkState.checkAll = checked
+  console.log('onCheckAllChange', e, checked)
+  formState.effectTimeRanges = checked ? [...plainOptions.value] : []
+}
+
+watch(
+  () => formState.effectTimeRanges,
+  (val) => {
+    checkState.indeterminate = !!val.length && val.length < plainOptions.value.length //设置全选按钮的半选状态
+    checkState.checkAll = val.length === plainOptions.value.length // 设置全选按钮的选中状态
+  },
+  {
+    immediate: true,
+  }
+)
+
+// 生效方式变化 周week、月month
+const effectTypeChange = (e: Event) => {
+  const value = (e.target as HTMLInputElement).value
+  console.log('effectTypeChange', e, value)
+
+  if (value === 'week') {
+    plainOptions.value = weekOptions
+    formState.effectTimeRanges = weekOptions
+  } else if (value === 'mouth') {
+    plainOptions.value = mouthOptions
+    formState.effectTimeRanges = mouthOptions
+  }
+  /* 置全选按钮的状态 */
+  checkState.indeterminate =
+    !!formState.effectTimeRanges.length &&
+    formState.effectTimeRanges.length < plainOptions.value.length // 设置全选按钮的半选状态
+  checkState.checkAll = formState.effectTimeRanges.length === plainOptions.value.length // 设置全选按钮的选中状态
+}
+
+// 添加时间段
+const addEffectTime = () => {
+  console.log('addEffectTime', formState.effectTimeFrame)
+  if (!formState.effectTimeFrame || !formState.effectTimeFrame.length) {
+    message.warn('请选择时间段')
+    return
+  }
+  formState.effectTimeFrames.push({
+    startTime: formState.effectTimeFrame[0],
+    endTime: formState.effectTimeFrame[1],
+  })
+  // formState.effectTimeFrame = [] // 清空选择的时间段
+}
+// 删除已添加的时间段
+const deleteEffectTimeItem = (e: Event, index: number) => {
+  console.log('deleteEffectTimeItem', e, index)
+  formState.effectTimeFrames.splice(index, 1)
+}
+
+const cancel = () => {
+  formRef?.value?.resetFields()
+  emit('update:open', false)
+}
+
+const submitLoading = ref(false)
+// 确定
+const submit = () => {
+  formRef?.value
+    ?.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
+      //   })
+    })
+    .catch((err) => {
+      console.log('校验失败', err)
+    })
+}
+</script>
+
+<style scoped lang="less">
+:deep(.ant-modal) {
+  .footer {
+    text-align: right;
+  }
+  .ant-modal-body {
+    padding: 12px 0;
+  }
+}
+
+:deep(.ant-checkbox-group) {
+  .ant-checkbox + span {
+    min-width: 32px;
+  }
+}
+
+:deep(.ant-tag) {
+  margin-inline-end: 0 !important;
+}
+</style>

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

@@ -328,7 +328,7 @@
             </div>
           </div>
 
-          <div class="mapConfig-item">
+          <div v-if="selectedBlock.isBed" class="mapConfig-item">
             <div class="mapConfig-item-label">呼吸检测:</div>
             <div class="mapConfig-item-content"> 默认开启 </div>
           </div>

+ 10 - 7
src/views/device/detail/components/infoCard/index.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="info">
-    <div class="info-header">
+    <div v-if="props.title" class="info-header">
       <div class="info-header-title">{{ props.title }}</div>
       <div class="info-header-extra"><slot name="extra"></slot></div>
     </div>
@@ -17,35 +17,37 @@ defineOptions({
 })
 
 type Props = {
-  title: string
+  title?: string
+  subtitle?: string
 }
 
 const props = withDefaults(defineProps<Props>(), {
   title: '',
+  subtitle: '',
 })
 </script>
 
 <style scoped lang="less">
 .info {
   border-radius: 10px;
-  padding: 12px;
-  border-radius: 10px;
   box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
   background-color: #fff;
-  padding: 16px;
   min-width: 350px;
   &-header {
     display: flex;
     justify-content: space-between;
     align-items: center;
-    margin-bottom: 10px;
+    padding: 12px;
+    border-bottom: 1px solid #e0e0e0;
+    line-height: 2;
+    font-weight: 600;
     &-title {
       font-size: 16px;
       font-weight: 600;
       color: #333;
     }
     &-extra {
-      font-size: 14px;
+      font-size: 16px;
     }
   }
 
@@ -53,6 +55,7 @@ const props = withDefaults(defineProps<Props>(), {
     display: flex;
     flex-direction: column;
     min-height: 350px;
+    padding: 12px;
   }
 }
 </style>

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

@@ -36,8 +36,12 @@ const props = withDefaults(defineProps<Props>(), {
   }
   &-content {
     color: #555;
-    font-size: 16px;
+    font-size: 14px;
+    word-break: break-all;
+    flex-grow: 1;
+    max-width: 250px;
     word-break: break-all;
+    line-height: 1.5;
   }
 }
 </style>

+ 48 - 0
src/views/device/detail/components/infoItemGroup/index.vue

@@ -0,0 +1,48 @@
+<template>
+  <div class="info-item-group">
+    <div v-if="props.title" class="info-item-group-title">
+      <span>{{ props.title }}</span>
+      <slot name="extra"></slot>
+    </div>
+
+    <div class="info-item-group-content"><slot></slot></div>
+  </div>
+</template>
+
+<script setup lang="ts">
+defineOptions({
+  name: 'InfoItemGroup',
+})
+
+type Props = {
+  title?: string
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  title: '',
+})
+</script>
+
+<style scoped lang="less">
+.info-item-group {
+  display: flex;
+  flex-direction: column;
+  margin-bottom: 10px;
+  &-title {
+    font-size: 16px;
+    font-weight: 600;
+    color: #333;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    border-bottom: 1px solid #e0e0e0;
+    padding: 12px;
+  }
+  &-content {
+    padding: 12px;
+  }
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+</style>

+ 83 - 56
src/views/device/detail/index.vue

@@ -2,7 +2,7 @@
   <a-spin :spinning="spinning">
     <div class="deviceDetail">
       <info-card
-        title="实时点位"
+        title="实时点位"
         :class="[
           furnitureItems && furnitureItems.some((item) => item.type === 'bed') ? 'pointCard' : '',
         ]"
@@ -163,64 +163,86 @@
         </div>
       </FullViewModal>
 
-      <info-card title="基本信息">
-        <template #extra>
-          <a-button type="primary" size="small" @click="roomConfigHandler('base')">
-            设备配置
-          </a-button>
-        </template>
-        <info-item label="设备ID">{{ detailState.clientId }}</info-item>
-        <info-item label="设备名称">{{ detailState.devName }}</info-item>
-        <info-item label="设备类型">{{ detailState.devType }}</info-item>
-        <info-item label="固件版本号">{{ detailState.software }}</info-item>
-        <info-item label="激活日期">{{ detailState.activeTime }}</info-item>
-        <info-item label="在离线状态">
-          <template v-if="detailState.clientId">
-            <a-tag
-              v-if="detailState.online === 0"
-              :bordered="false"
-              :color="deviceOnlineStateMap[detailState.online].color"
-              >{{ deviceOnlineStateMap[detailState.online].name }}</a-tag
-            >
-            <a-tag
-              v-if="detailState.online === 1"
-              :bordered="false"
-              :color="deviceOnlineStateMap[detailState.online].color"
-              >{{ deviceOnlineStateMap[detailState.online].name }}</a-tag
-            >
+      <info-card>
+        <info-item-group title="基本信息">
+          <template #extra>
+            <a-button type="primary" size="small" @click="roomConfigHandler('base')">
+              设备配置
+            </a-button>
           </template>
-        </info-item>
-        <info-item label="归属租户">{{ detailState.tenantName }}</info-item>
-        <info-item label="统计信息">
-          <a-button
-            v-if="detailState.clientId"
-            type="link"
-            size="small"
-            @click="viewDeviceHistoryInfo"
-          >
-            点击查看
-          </a-button>
-        </info-item>
+          <info-item label="设备ID">{{ detailState.clientId }}</info-item>
+          <info-item label="设备名称">{{ detailState.devName }}</info-item>
+          <info-item label="设备类型">{{ detailState.devType }}</info-item>
+          <info-item label="固件版本号">{{ detailState.software }}</info-item>
+          <info-item label="激活日期">{{ detailState.activeTime }}</info-item>
+          <info-item label="在离线状态">
+            <template v-if="detailState.clientId">
+              <a-tag
+                v-if="detailState.online === 0"
+                :bordered="false"
+                :color="deviceOnlineStateMap[detailState.online].color"
+                >{{ deviceOnlineStateMap[detailState.online].name }}</a-tag
+              >
+              <a-tag
+                v-if="detailState.online === 1"
+                :bordered="false"
+                :color="deviceOnlineStateMap[detailState.online].color"
+                >{{ deviceOnlineStateMap[detailState.online].name }}</a-tag
+              >
+            </template>
+          </info-item>
+          <info-item label="归属租户">{{ detailState.tenantName }}</info-item>
+          <info-item label="统计信息">
+            <a-button
+              v-if="detailState.clientId"
+              type="link"
+              size="small"
+              @click="viewDeviceHistoryInfo"
+            >
+              查看详情
+            </a-button>
+          </info-item>
+        </info-item-group>
+
+        <info-item-group title="安装参数">
+          <info-item label="安装高度">
+            <template v-if="detailState.height"> {{ detailState.height }} cm</template>
+          </info-item>
+          <info-item label="检测区域">
+            <template v-if="detailState.length || detailState.width">
+              {{ detailState.length || 0 }} x {{ detailState.width || 0 }} cm
+            </template>
+          </info-item>
+          <info-item label="安装位置">
+            <template v-if="detailState.clientId">
+              {{
+                deviceInstallPositionNameMap[
+                  detailState.installPosition as keyof typeof deviceInstallPositionNameMap
+                ]
+              }}
+            </template>
+          </info-item>
+        </info-item-group>
       </info-card>
 
-      <info-card title="安装参数">
-        <info-item label="安装高度">
-          <template v-if="detailState.height"> {{ detailState.height }} cm</template>
-        </info-item>
-        <info-item label="检测区域">
-          <template v-if="detailState.length || detailState.width">
-            {{ detailState.length || 0 }} x {{ detailState.width || 0 }} cm
-          </template>
-        </info-item>
-        <info-item label="安装位置">
-          <template v-if="detailState.clientId">
-            {{
-              deviceInstallPositionNameMap[
-                detailState.installPosition as keyof typeof deviceInstallPositionNameMap
-              ]
-            }}
+      <info-card>
+        <info-item-group title="告警计划">
+          <template #extra>
+            <a-space>
+              <a-button type="primary" size="small" @click="alarmPlanVisible = true">
+                新增计划
+              </a-button>
+            </a-space>
           </template>
-        </info-item>
+
+          <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>
+        </info-item-group>
       </info-card>
 
       <deviceConfigDrawer
@@ -243,6 +265,8 @@
         :dev-id="`${detailState.devId as string}`"
         :title="`${detailState.devName || ''} 统计信息`"
       ></deviceStatsDrawer>
+
+      <alarmPlanModal v-model:open="alarmPlanVisible"></alarmPlanModal>
     </div>
   </a-spin>
 </template>
@@ -250,6 +274,7 @@
 <script setup lang="ts">
 import infoCard from './components/infoCard/index.vue'
 import infoItem from './components/infoItem/index.vue'
+import infoItemGroup from './components/infoItemGroup/index.vue'
 import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
 import { useRoute } from 'vue-router'
 import { message } from 'ant-design-vue'
@@ -265,6 +290,7 @@ import BreathLineChart from './components/breathLineChart/index.vue'
 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'
 
 defineOptions({
   name: 'DeviceDetail',
@@ -558,7 +584,8 @@ onUnmounted(() => {
   if (mqttTimeout) clearTimeout(mqttTimeout)
 })
 
-const openFullView = ref(false)
+const openFullView = ref(false) // 全屏展示点位图
+const alarmPlanVisible = ref(false) // 告警计划弹窗
 </script>
 
 <style scoped lang="less">