Przeglądaj źródła

feat(设备详情): 1、新增床的子区域并优化家具配置交互与保存逻辑;2、新增呼吸率曲线图表并优化布局样式;

refactor(家具卡片): 使用折叠面板重构家具列表组件
style(信息卡片): 调整卡片阴影和间距样式
fix(区域配置): 修复床区域删除确认逻辑
liujia 2 miesięcy temu
rodzic
commit
7ff5269

+ 3 - 0
components.d.ts

@@ -11,6 +11,8 @@ declare module 'vue' {
     AAvatar: typeof import('ant-design-vue/es')['Avatar']
     AButton: typeof import('ant-design-vue/es')['Button']
     ACascader: typeof import('ant-design-vue/es')['Cascader']
+    ACollapse: typeof import('ant-design-vue/es')['Collapse']
+    ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
     AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
     ADrawer: typeof import('ant-design-vue/es')['Drawer']
     ADropdown: typeof import('ant-design-vue/es')['Dropdown']
@@ -29,6 +31,7 @@ declare module 'vue' {
     AModal: typeof import('ant-design-vue/es')['Modal']
     APageHeader: typeof import('ant-design-vue/es')['PageHeader']
     APagination: typeof import('ant-design-vue/es')['Pagination']
+    APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
     ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
     ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
     ARangePicker: typeof import('ant-design-vue/es')['RangePicker']

+ 168 - 0
src/views/device/detail/components/breathLineChart/index.vue

@@ -0,0 +1,168 @@
+<template>
+  <div ref="lineChartRef" class="chart"></div>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted } from 'vue'
+import * as echarts from 'echarts'
+
+defineOptions({ name: 'BreathLineChart' })
+
+const lineChartRef = ref<HTMLDivElement | null>(null)
+
+onMounted(() => {
+  const lineChart = echarts.init(lineChartRef.value!)
+
+  // -----------------------------
+  // 配置显示的时间和数据点
+  // -----------------------------
+  const totalSeconds = 60 // 显示1分钟的数据
+  const step = 1 // 每秒一个点
+  const maxPoints = totalSeconds / step + 1
+  const xAxisData = Array.from({ length: maxPoints }, (_, i) => i * step)
+  const lineData: number[] = Array(maxPoints).fill(20) // 初始基准值为20
+
+  let t = 0 // 时间计数器(秒)
+
+  // -----------------------------
+  // 模拟呼吸参数
+  // -----------------------------
+  const bpm = 50 // 平均呼吸次数(次/分钟),可以修改
+  const basePeriod = 60 / bpm // 平均每次呼吸周期(秒)
+  let period = basePeriod // 当前周期
+  let amplitude = 10 // 波动幅度
+
+  const yBase = 20 // y轴基准线,波形围绕此值波动
+
+  // 用于计算实时BPM
+  const peakTimes: number[] = []
+
+  // -----------------------------
+  // 初始化图表配置
+  // -----------------------------
+  const option = {
+    title: [
+      {
+        text: '呼吸率曲线', // 主标题
+        left: 'center',
+        top: 20,
+        textStyle: { fontSize: 16 },
+      },
+      {
+        text: 'BPM: 0 次/分钟', // 实时呼吸率显示
+        left: 'center',
+        top: 50, // 放在主标题下方
+        textStyle: { fontSize: 12, color: '#007bff' },
+        id: 'bpmText',
+      },
+      {
+        text: '时间 (秒)', // X轴文字
+        left: 'right',
+        top: 'bottom',
+        textStyle: { fontSize: 12, color: '#888888', fontWeight: 'normal' },
+      },
+    ],
+    grid: { left: 60, right: 20, top: 80, bottom: 50 }, // 图表边距
+    xAxis: { type: 'category', data: xAxisData, boundaryGap: false, splitLine: { show: false } },
+    yAxis: {
+      type: 'value',
+      min: 0,
+      max: 40,
+      name: '呼吸幅度(次)',
+      nameLocation: 'end',
+      nameGap: 20,
+      nameTextStyle: { fontSize: 12, color: '#888888', fontWeight: 'normal' },
+      splitLine: { show: true },
+    },
+    series: [
+      {
+        type: 'line' as const,
+        smooth: true,
+        symbol: 'none' as const,
+        lineStyle: { color: '#007bff', width: 2 }, // 折线样式
+        data: lineData.slice(),
+      },
+    ],
+    animation: true,
+    animationDuration: 100,
+  }
+
+  lineChart.setOption(option)
+
+  // -----------------------------
+  // 定时生成模拟呼吸数据
+  // -----------------------------
+  setInterval(() => {
+    t += step // 更新时间
+
+    // -----------------------------
+    // 调整波形随机性
+    // -----------------------------
+    if (Math.random() < 0.1) {
+      // 每隔一段时间随机调整周期和幅度,使波形更自然
+      period = basePeriod * (0.8 + Math.random() * 0.4) // ±20% ~ ±40%
+      amplitude = 8 + Math.random() * 6 // 幅度 8~14
+    }
+
+    // -----------------------------
+    // 生成当前点的呼吸值
+    // -----------------------------
+    // 正弦波 + 小扰动
+    const val = yBase + amplitude * Math.sin(((2 * Math.PI) / period) * t) + (Math.random() * 2 - 1)
+    const clampedVal = Math.min(40, Math.max(0, val)) // 保证不超过y轴范围
+
+    // -----------------------------
+    // 更新折线数据
+    // -----------------------------
+    lineData.shift() // 删除最旧的数据点
+    lineData.push(clampedVal) // 添加新数据点
+
+    // -----------------------------
+    // 检测波峰用于计算实时BPM
+    // -----------------------------
+    const lastIndex = lineData.length - 1
+    if (lastIndex >= 2) {
+      // 当前点大于前一点和后一点,则为峰
+      if (
+        lineData[lastIndex - 2] < lineData[lastIndex - 1] &&
+        lineData[lastIndex] < lineData[lastIndex - 1]
+      ) {
+        const peakTime = t
+        peakTimes.push(peakTime)
+        // 保留最近60秒的峰,用于计算BPM
+        while (peakTimes.length && peakTimes[0] < t - 60) peakTimes.shift()
+      }
+    }
+
+    // -----------------------------
+    // 计算实时BPM
+    // -----------------------------
+    const currentBPM = peakTimes.length
+
+    // -----------------------------
+    // 更新图表
+    // -----------------------------
+    lineChart.setOption(
+      {
+        series: [{ ...option.series[0], data: lineData.slice() }], // 更新折线数据
+        title: [{}, { text: `BPM: ${currentBPM} 次/分钟` }, {}], // 更新实时BPM
+      },
+      { notMerge: false }
+    )
+  }, 100) // 每0.5秒刷新一次数据
+
+  // -----------------------------
+  // 自适应窗口大小
+  // -----------------------------
+  window.addEventListener('resize', () => lineChart.resize())
+})
+</script>
+
+<style scoped>
+.chart {
+  width: 100%;
+  height: 300px;
+  background: #fff;
+  border-radius: 4px;
+}
+</style>

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

@@ -1,5 +1,12 @@
 <template>
   <a-spin :spinning="spinning">
+    <furnitureCard
+      v-if="isEditDraggable"
+      v-model:is-edit="isEditDraggable"
+      :style="{ marginTop: '30px' }"
+      @add="addHnadler"
+    ></furnitureCard>
+
     <div class="viewer">
       <div class="viewer-header">
         <div>
@@ -18,8 +25,8 @@
               type="primary"
               size="small"
               :disabled="!isEditDraggable"
-              @click="saveFurnitureMapConfig"
-              >保存家具</a-button
+              @click="saveAllConfig"
+              >保存配置</a-button
             >
           </a-space>
         </div>
@@ -31,6 +38,7 @@
           :style="{
             width: `${areaWidth}px`,
             height: `${areaHeight}px`,
+            cursor: !isEditDraggable ? 'no-drop' : 'default',
           }"
           @mousedown="handleMouseDownMapCanvas"
         >
@@ -130,18 +138,23 @@
           <div class="mapConfig-item">
             <div class="mapConfig-item-label">删除家具:</div>
             <div class="mapConfig-item-content">
-              <DeleteOutlined @click="deleteFurnitureIcon(clickedDragItem?.nanoid || '')" />
+              <a-popconfirm
+                v-if="clickedDragItem.type === 'bed'"
+                placement="bottom"
+                @confirm="deleteFurnitureBed(clickedDragItem?.nanoid || '')"
+              >
+                <template #icon><question-circle-outlined style="color: red" /></template>
+                <template #title>
+                  <div>删除 “床”也会删除子区域,</div>
+                  <div>是否继续删除?</div>
+                </template>
+                <DeleteOutlined />
+              </a-popconfirm>
+              <DeleteOutlined v-else @click="deleteFurnitureIcon(clickedDragItem?.nanoid || '')" />
             </div>
           </div>
         </div>
       </div>
-
-      <furnitureCard
-        v-if="isEditDraggable"
-        v-model:is-edit="isEditDraggable"
-        :style="{ marginTop: '30px' }"
-        @add="addHnadler"
-      ></furnitureCard>
     </div>
 
     <div class="viewer">
@@ -152,10 +165,9 @@
         </div>
         <div class="viewer-header-extra">
           <a-space>
-            <a-button size="small" @click="createNewBlock">{{
+            <a-button size="small" :disabled="!isEditDraggable" @click="createNewBlock">{{
               isCreating ? '创建中...' : '新建区域'
             }}</a-button>
-            <a-button type="primary" size="small" @click="saveBlockConfig">保存子区域</a-button>
           </a-space>
         </div>
       </div>
@@ -166,7 +178,7 @@
           :style="{
             width: `${areaWidth}px`,
             height: `${areaHeight}px`,
-            cursor: isCreating ? 'crosshair' : 'default',
+            cursor: !isEditDraggable ? 'no-drop' : isCreating ? 'crosshair' : 'default',
           }"
           @mousedown="handleMouseDown"
         >
@@ -208,14 +220,21 @@
               top: `${block.y}px`,
               width: `${block.width}px`,
               height: `${block.height}px`,
-              border: `2px solid ${block.isActice ? 'yellow' : '#1890ff'}`,
+              border: `2px solid ${block?.isBed ? '#1abc1a' : block.isActice ? 'yellow' : '#1890ff'}`,
               position: 'absolute',
-              cursor: 'move',
+              cursor: !isEditDraggable ? 'no-drop' : 'move',
+              backgroundColor: block.isBed ? 'rgba(26, 188, 26, 0.1)' : 'rgba(24, 144, 255, 0.1)',
             }"
             @mousedown="startDrag(block, $event)"
             @click="selectBlock(block)"
           >
-            <div class="resize-handle" @mousedown.stop="startResize(block, $event)">
+            <div
+              class="resize-handle"
+              :style="{
+                backgroundColor: block.isBed ? '#1abc1a' : '#1890ff',
+              }"
+              @mousedown.stop="startResize(block, $event)"
+            >
               {{ blockIndex + 1 }}
             </div>
           </div>
@@ -303,6 +322,11 @@
           </div>
 
           <div class="mapConfig-item">
+            <div class="mapConfig-item-label">呼吸检测:</div>
+            <div class="mapConfig-item-content"> 默认开启 </div>
+          </div>
+
+          <div class="mapConfig-item">
             <div class="mapConfig-item-label">删除区域:</div>
             <div class="mapConfig-item-content">
               <DeleteOutlined @click="deleteBlockArea(selectedBlock.id || '')" />
@@ -332,6 +356,7 @@ import {
   ArrowLeftOutlined,
   ArrowRightOutlined,
   DeleteOutlined,
+  QuestionCircleOutlined,
 } from '@ant-design/icons-vue'
 
 defineOptions({
@@ -416,7 +441,7 @@ const fetchRoomLayout = async () => {
 
     if (subRegions) {
       // 将接口的子区域,添加在子区域画布上
-      subRegions.forEach((item) => {
+      subRegions.forEach((item, index) => {
         blocks.value.push({
           // 本地需要使用的数据
           id: nanoid(),
@@ -431,21 +456,24 @@ const fetchRoomLayout = async () => {
           isActice: false,
           isTracking: Boolean(item.trackPresence),
           isFalling: Boolean(item.excludeFalling),
+          isBed: index === 0 && mapCanvasList.value.some((item) => item.type === 'bed'),
           // 来自接口回显的数据
           startXx: item.startXx,
           stopXx: item.stopXx,
           startYy: item.startYy,
           stopYy: item.stopYy,
-          startZz: 0,
-          stopZz: 0,
-          isLowSnr: 0,
-          isDoor: 0,
-          presenceEnterDuration: 3,
-          presenceExitDuration: 3,
+          startZz: item.startZz,
+          stopZz: item.stopZz,
+          isLowSnr: item.isLowSnr,
+          isDoor: item.isDoor,
+          presenceEnterDuration: item.presenceEnterDuration,
+          presenceExitDuration: item.presenceExitDuration,
           trackPresence: item.trackPresence,
           excludeFalling: item.excludeFalling,
         })
       })
+
+      console.log('🚀', blocks.value)
     }
     spinning.value = false
   } catch (error) {
@@ -479,6 +507,15 @@ const isEditDraggable = ref(false)
 // 家具列表添加
 const addHnadler = (icon: FurnitureIconType) => {
   console.log('addHnadler', icon)
+  // 检查画布上是否已经添加过了 icon 为 bed 的家具
+  if (icon === 'bed') {
+    const isExist = mapCanvasList.value.some((item) => item.type === icon)
+    if (isExist) {
+      message.error('床已经添加过了,不可重复添加')
+      return
+    }
+  }
+
   const { originOffsetX, originOffsetY } = getOriginPosition()
   // 家具原始宽高
   const originWidth = furnitureIconSizeMap[icon].width || 30
@@ -496,7 +533,41 @@ const addHnadler = (icon: FurnitureIconType) => {
     nanoid: nanoid(),
     isActice: false,
   })
-  message.success('已添加')
+  message.success('已添加家具')
+  if (icon === 'bed') {
+    // 同步添加一个子区域
+    blocks.value.unshift({
+      // 本地用
+      id: nanoid(),
+      x: 20,
+      y: 15,
+      ox: -150,
+      oy: 180,
+      width: originWidth,
+      height: originHeight,
+      isDragging: false,
+      isResizing: false,
+      isActice: false,
+      isTracking: false,
+      isFalling: false,
+      isBed: true,
+      // 接口用
+      startXx: -150,
+      stopXx: -100,
+      startYy: 180,
+      stopYy: 120,
+      startZz: 0,
+      stopZz: 0,
+      isLowSnr: 1,
+      isDoor: 0,
+      presenceEnterDuration: 3,
+      presenceExitDuration: 3,
+      trackPresence: 0,
+      excludeFalling: 0,
+    })
+    console.log('blocks', blocks.value)
+    message.success('已添加子区域')
+  }
 }
 
 const contentEl = ref<HTMLElement>()
@@ -654,6 +725,15 @@ const deleteFurnitureIcon = (nanoid: string) => {
   }
 }
 
+// 删除家具床
+const deleteFurnitureBed = (nanoid: string) => {
+  console.log('deleteFurnitureBed', nanoid)
+  // 先从家具画布移除床
+  deleteFurnitureIcon(nanoid)
+  // 再从子区域画布删除对应的子区域
+  blocks.value.shift()
+}
+
 // 新增区块类型
 interface BlockItem {
   // 本地用
@@ -667,8 +747,9 @@ interface BlockItem {
   isDragging: boolean // 是否正在拖动
   isResizing: boolean // 是否正在调整大小
   isActice: boolean // 是否选中
-  isTracking: boolean // 是否开启区域跟踪  0-否,1-是
-  isFalling: boolean // 是否屏蔽区域跌倒检测  0-否,1-是
+  isTracking: boolean // 是否开启区域跟踪  0-否,1-是 对应 trackPresence 字段
+  isFalling: boolean // 是否屏蔽区域跌倒检测  0-否,1-是 对应 excludeFalling 字段
+  isBed?: boolean // 是否是床 本地判断使用
   // 接口用
   startXx: number // 屏蔽子区域X开始
   stopXx: number // 屏蔽子区域X结束
@@ -676,7 +757,7 @@ interface BlockItem {
   stopYy: number // 屏蔽子区域Y结束
   startZz: number // 屏蔽子区域Z开始
   stopZz: number // 屏蔽子区域Z结束
-  isLowSnr: number // 默认0
+  isLowSnr: number // 是否为床  0-不是,1-是
   isDoor: number // 是否是门 0-否,1-是 默认0
   presenceEnterDuration: number // 	人员进入时间 默认3
   presenceExitDuration: number // 人员离开时间 默认3
@@ -711,6 +792,7 @@ const getContainerRect = () => {
 
 // 鼠标事件处理
 const handleMouseDown = (e: MouseEvent) => {
+  if (!isEditDraggable.value) return
   console.log('handleMouseDown', e)
   blocks.value.forEach((item) => {
     item.isActice = false
@@ -789,6 +871,7 @@ const handleMouseUp = () => {
 
 // 区块拖动
 const startDrag = (block: BlockItem, e: MouseEvent) => {
+  if (!isEditDraggable.value) return
   console.log('startDrag', block)
   e.stopPropagation()
   block.isDragging = true
@@ -826,6 +909,7 @@ const startDrag = (block: BlockItem, e: MouseEvent) => {
 }
 
 const selectBlock = (block: BlockItem) => {
+  if (!isEditDraggable.value) return
   console.log('selectBlock', block)
   selectedBlock.value = block
   blocks.value.forEach((item) => {
@@ -834,37 +918,37 @@ const selectBlock = (block: BlockItem) => {
 }
 
 // 保存子区域配置
-const saveBlockConfig = () => {
-  const blockData = blocks.value.map((item) => {
-    return {
-      startXx: item.startXx,
-      stopXx: item.stopXx,
-      startYy: item.startYy,
-      stopYy: item.stopYy,
-      startZz: Number(item.startZz) || 0,
-      stopZz: Number(item.stopZz) || 0,
-      isLowSnr: item.isLowSnr,
-      isDoor: item.isDoor,
-      presenceEnterDuration: item.presenceEnterDuration,
-      presenceExitDuration: item.presenceExitDuration,
-      trackPresence: Number(item.isTracking),
-      excludeFalling: Number(item.isFalling),
-    }
-  })
-  console.log('当前所有区块配置:', blockData)
-  try {
-    const res = roomApi.saveRoomInfo({
-      roomId: deviceRoomId.value,
-      devId: props.devId,
-      subRegions: blockData,
-    })
-    console.log('saveBlockConfig 保存成功', res)
-    message.success('保存成功')
-    emit('success')
-  } catch (error) {
-    console.error('saveBlockConfig 保存失败', error)
-  }
-}
+// const saveBlockConfig = () => {
+//   const blockData = blocks.value.map((item) => {
+//     return {
+//       startXx: item.startXx,
+//       stopXx: item.stopXx,
+//       startYy: item.startYy,
+//       stopYy: item.stopYy,
+//       startZz: Number(item.startZz) || 0,
+//       stopZz: Number(item.stopZz) || 0,
+//       isLowSnr: item.isLowSnr,
+//       isDoor: item.isDoor,
+//       presenceEnterDuration: item.presenceEnterDuration,
+//       presenceExitDuration: item.presenceExitDuration,
+//       trackPresence: Number(item.isTracking),
+//       excludeFalling: Number(item.isFalling),
+//     }
+//   })
+//   console.log('当前所有区块配置:', blockData)
+//   try {
+//     const res = roomApi.saveRoomInfo({
+//       roomId: deviceRoomId.value,
+//       devId: props.devId,
+//       subRegions: blockData,
+//     })
+//     console.log('saveBlockConfig 保存成功', res)
+//     message.success('保存成功')
+//     emit('success')
+//   } catch (error) {
+//     console.error('saveBlockConfig 保存失败', error)
+//   }
+// }
 
 /**
  * 获取坐标位置
@@ -956,8 +1040,56 @@ const initRadarIcon = () => {
 }
 
 // 保存家具配置
-const saveFurnitureMapConfig = () => {
-  console.log('saveFurnitureMapConfig', mapCanvasList.value)
+// const saveFurnitureMapConfig = () => {
+//   console.log('saveFurnitureMapConfig', mapCanvasList.value)
+//   try {
+//     const res = roomApi.saveRoomInfo({
+//       roomId: deviceRoomId.value,
+//       devId: props.devId,
+//       furnitures: mapCanvasList.value
+//         .filter((item) => item.type !== 'radar')
+//         .map((item) => {
+//           return {
+//             name: item.name,
+//             type: item.type as FurnitureType,
+//             width: item.width,
+//             length: item.height,
+//             top: item.top,
+//             left: item.left,
+//             rotate: item.rotate as 0 | 90 | 180 | 270,
+//             x: item?.x || 0,
+//             y: item?.y || 0,
+//           }
+//         }),
+//     })
+//     console.log('保存家具配置 成功', res)
+//     message.success('保存成功')
+//     emit('success')
+//   } catch (error) {
+//     console.error('保存家具配置 失败', error)
+//   }
+// }
+
+// 保存所有配置
+const saveAllConfig = () => {
+  console.log('保存所有配置')
+  const blockData = blocks.value.map((item) => {
+    return {
+      startXx: item.startXx,
+      stopXx: item.stopXx,
+      startYy: item.startYy,
+      stopYy: item.stopYy,
+      startZz: Number(item.startZz) || 0,
+      stopZz: Number(item.stopZz) || 0,
+      isLowSnr: item.isLowSnr,
+      isDoor: item.isDoor,
+      presenceEnterDuration: item.presenceEnterDuration,
+      presenceExitDuration: item.presenceExitDuration,
+      trackPresence: Number(item.isTracking),
+      excludeFalling: Number(item.isFalling),
+    }
+  })
+  console.log('当前所有区块配置:', blockData)
   try {
     const res = roomApi.saveRoomInfo({
       roomId: deviceRoomId.value,
@@ -977,12 +1109,13 @@ const saveFurnitureMapConfig = () => {
             y: item?.y || 0,
           }
         }),
+      subRegions: blockData,
     })
-    console.log('保存家具配置 成功', res)
+    console.log('保存所有配置 成功', res)
     message.success('保存成功')
     emit('success')
   } catch (error) {
-    console.error('保存家具配置 失败', error)
+    console.error('保存所有配置 失败', error)
   }
 }
 
@@ -1072,7 +1205,7 @@ const deleteBlockArea = (id: string) => {
   padding: 10px;
   min-width: 500px;
   flex-shrink: 0;
-  margin-top: 10px;
+  // margin-top: 10px;
 
   &-header {
     display: flex;

+ 51 - 27
src/views/device/detail/components/furnitureCard/index.vue

@@ -1,36 +1,42 @@
 <template>
-  <div class="toolbar">
-    <div class="toolbar-header">
-      <div class="toolbar-header-title">家具列表</div>
-      <div class="toolbar-header-extra"><slot name="extra"></slot></div>
-    </div>
-
-    <div class="toolbar-content">
-      <div class="toolbar-content-item">
-        <div class="toolbar-content-item-header">客厅</div>
-        <furniture-list :icons="livingroomIcons" @add="add"></furniture-list>
+  <a-collapse v-model:activeKey="activeKey" ghost expand-icon-position="end">
+    <a-collapse-panel key="1">
+      <template #header>
+        <div class="toolbar-header">
+          <div class="toolbar-header-title">家具列表</div>
+          <div class="toolbar-header-extra"><slot name="extra"> </slot></div>
+        </div>
+      </template>
+      <div class="toolbar">
+        <div class="toolbar-content">
+          <div class="toolbar-content-item">
+            <div class="toolbar-content-item-header">客厅</div>
+            <furniture-list :icons="livingroomIcons" @add="add"></furniture-list>
+          </div>
+
+          <div class="toolbar-content-item">
+            <div class="toolbar-content-item-header">餐厅</div>
+            <furniture-list :icons="diningroomIcons" @add="add"></furniture-list>
+          </div>
+
+          <div class="toolbar-content-item">
+            <div class="toolbar-content-item-header">卧室</div>
+            <furniture-list :icons="bedroomIocns" @add="add"></furniture-list>
+          </div>
+
+          <div class="toolbar-content-item">
+            <div class="toolbar-content-item-header">卫生间</div>
+            <furniture-list :icons="bathroomIocns" @add="add"></furniture-list>
+          </div>
+        </div>
       </div>
-
-      <div class="toolbar-content-item">
-        <div class="toolbar-content-item-header">餐厅</div>
-        <furniture-list :icons="diningroomIcons" @add="add"></furniture-list>
-      </div>
-
-      <div class="toolbar-content-item">
-        <div class="toolbar-content-item-header">卧室</div>
-        <furniture-list :icons="bedroomIocns" @add="add"></furniture-list>
-      </div>
-
-      <div class="toolbar-content-item">
-        <div class="toolbar-content-item-header">卫生间</div>
-        <furniture-list :icons="bathroomIocns" @add="add"></furniture-list>
-      </div>
-    </div>
-  </div>
+    </a-collapse-panel>
+  </a-collapse>
 </template>
 
 <script setup lang="ts">
 import type { FurnitureIconType } from '@/types/furniture'
+import { ref } from 'vue'
 
 defineOptions({
   name: 'furnitureCard',
@@ -50,6 +56,8 @@ const emit = defineEmits<{
 //   }
 // )
 
+const activeKey = ref(['1'])
+
 // 客厅图标
 const livingroomIcons = [
   'living_sofa',
@@ -129,4 +137,20 @@ const add = (icon: FurnitureIconType) => {
     }
   }
 }
+
+.ant-collapse {
+  margin-top: 0 !important;
+}
+:deep(.ant-collapse-header) {
+  padding: 0 12px !important;
+
+  .ant-collapse-arrow {
+    font-size: 14px !important;
+    color: #1677ff !important;
+  }
+}
+
+:deep(.ant-collapse-content-box) {
+  padding: 0 !important;
+}
 </style>

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

@@ -27,9 +27,13 @@ const props = withDefaults(defineProps<Props>(), {
 
 <style scoped lang="less">
 .info {
-  background-color: #f5f5f5;
   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;
@@ -48,6 +52,7 @@ const props = withDefaults(defineProps<Props>(), {
   &-content {
     display: flex;
     flex-direction: column;
+    min-height: 350px;
   }
 }
 </style>

+ 58 - 50
src/views/device/detail/index.vue

@@ -2,7 +2,7 @@
   <a-spin :spinning="spinning">
     <div class="deviceDetail">
       <div class="radarMap">
-        <info-card title="房间监测">
+        <info-card title="点位图">
           <template #extra>
             <a-button type="primary" size="small" @click="roomConfigHandler('area')">
               区域配置
@@ -67,22 +67,29 @@
               />
             </div>
           </div>
+
+          <div
+            v-if="furnitureItems && furnitureItems.some((item) => item.type === 'bed')"
+            class="breathLine"
+          >
+            <BreathLineChart></BreathLineChart>
+          </div>
         </info-card>
       </div>
 
-      <div class="infos">
-        <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.hardware }}</info-item>
-          <info-item label="激活日期">{{ detailState.activeTime }}</info-item>
-          <info-item label="在离线状态">
+      <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.hardware }}</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"
@@ -95,31 +102,33 @@
               :color="deviceOnlineStateMap[detailState.online].color"
               >{{ deviceOnlineStateMap[detailState.online].name }}</a-tag
             >
-          </info-item>
-          <info-item label="归属租户">{{ detailState.tenantName }}</info-item>
-          <info-item label="统计信息">
-            <a-button type="link" size="small" @click="viewDeviceHistoryInfo"> 点击查看 </a-button>
-          </info-item>
-        </info-card>
+          </template>
+        </info-item>
+        <info-item label="归属租户">{{ detailState.tenantName }}</info-item>
+        <info-item label="统计信息">
+          <a-button type="link" size="small" @click="viewDeviceHistoryInfo"> 点击查看 </a-button>
+        </info-item>
+      </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="安装位置">
+      <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-item>
-        </info-card>
-      </div>
+          </template>
+        </info-item>
+      </info-card>
 
       <deviceConfigDrawer
         v-model:open="configDrawerOpen"
@@ -159,6 +168,7 @@ import type { DeviceDetailData } from '@/api/device/types'
 import { deviceOnlineStateMap, deviceInstallPositionNameMap } from '@/const/device'
 import deviceConfigDrawer from './components/deviceConfig/index.vue'
 import deviceStatsDrawer from './components/deviceStatsDrawer/index.vue'
+import BreathLineChart from './components/breathLineChart/index.vue'
 
 defineOptions({
   name: 'DeviceDetail',
@@ -424,25 +434,22 @@ onUnmounted(() => {
 
 <style scoped lang="less">
 .deviceDetail {
-  background-color: #fff;
   display: flex;
-  justify-content: space-between;
-  min-height: 500px;
-  padding: 20px;
+  flex-wrap: wrap;
+  gap: 16px;
 
   .radarMap {
     flex-shrink: 0;
-    margin-right: 16px;
     min-width: 430px;
     min-height: 400px;
     background-color: #f5f5f5;
     border-radius: 10px;
 
-    // :deep(.info) {
-    //   &-content {
-    //     align-items: center !important;
-    //   }
-    // }
+    :deep(.info) {
+      &-content {
+        flex-direction: row;
+      }
+    }
 
     .radarBox {
       position: relative;
@@ -451,7 +458,8 @@ onUnmounted(() => {
         linear-gradient(to right, rgba(0, 0, 0, 0.1) 1px, transparent 1px);
       background-size: 20px 20px;
       border: 1px solid rgba(0, 0, 0, 0.8);
-      // overflow: hidden;
+      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+      overflow: hidden;
 
       .furniture-item {
         position: absolute;
@@ -462,12 +470,12 @@ onUnmounted(() => {
       }
     }
   }
+}
 
-  .infos {
-    flex-grow: 1;
-    display: grid;
-    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
-    gap: 16px;
-  }
+.breathLine {
+  margin-left: 16px;
+  flex-shrink: 0;
+  flex-grow: 1;
+  flex-basis: 350px;
 }
 </style>