Эх сурвалжийг харах

Merge dev into prod (keep prod config files)

liujia 1 сар өмнө
parent
commit
a91c9b0af6
41 өөрчлөгдсөн 2864 нэмэгдсэн , 21 устгасан
  1. 21 0
      CHANGELOG.md
  2. 1 0
      components.d.ts
  3. 2 1
      package.json
  4. 12 0
      pnpm-lock.yaml
  5. 7 0
      src/api/stats/index.ts
  6. 28 0
      src/api/stats/types.ts
  7. 1 1
      src/api/system/index.ts
  8. 2 0
      src/components/baseModal/index.vue
  9. 34 0
      src/directives/disabled.ts
  10. 15 0
      src/layout/index.vue
  11. 2 0
      src/main.ts
  12. 6 0
      src/router/index.ts
  13. 13 0
      src/utils/useChartResize.ts
  14. 141 0
      src/utils/useResponsiveLayout.ts
  15. 7 0
      src/views/alarm/history/const.ts
  16. 14 1
      src/views/alarm/history/index.vue
  17. BIN
      src/views/dashboard/assets/img/map.jpg
  18. 89 0
      src/views/dashboard/components/AlarmHistoryCard/index.vue
  19. 85 0
      src/views/dashboard/components/AlertFallCompareCard/index.vue
  20. 81 0
      src/views/dashboard/components/DeviceAgeCard/index.vue
  21. 86 0
      src/views/dashboard/components/DeviceLocationCard/index.vue
  22. 141 0
      src/views/dashboard/components/DeviceOnlineRateCard/index.vue
  23. 100 0
      src/views/dashboard/components/ElderActivityCard/index.vue
  24. 90 0
      src/views/dashboard/components/FallingHistoryCard/index.vue
  25. 86 0
      src/views/dashboard/components/ObjectDistributionCard/index.vue
  26. 127 0
      src/views/dashboard/components/PeopleDetectedCard/index.vue
  27. 127 0
      src/views/dashboard/components/TechCard/index.vue
  28. 107 0
      src/views/dashboard/components/dataCard/index.vue
  29. 832 0
      src/views/dashboard/components/screen/index.vue
  30. 68 0
      src/views/dashboard/components/statusItem/index.vue
  31. 385 0
      src/views/dashboard/index.vue
  32. 25 0
      src/views/dashboard/types/index.ts
  33. 52 5
      src/views/device/detail/components/alarmPlanModal/index.vue
  34. 4 0
      src/views/device/detail/components/deviceAreaConfig/index.vue
  35. 15 1
      src/views/device/detail/components/deviceBaseConfig/index.vue
  36. 4 0
      src/views/device/detail/components/deviceConfig/index.vue
  37. 29 6
      src/views/device/detail/index.vue
  38. 1 1
      src/views/device/list/components/addDevice/index.vue
  39. 3 3
      src/views/device/list/const.ts
  40. 18 0
      src/views/device/list/index.vue
  41. 3 2
      src/views/system/config/index.vue

+ 21 - 0
CHANGELOG.md

@@ -1,4 +1,25 @@
 
+## v0.6.2 (2025-09-15)
+- feat: 调整设备固件版本号为hardware; (1d7cd19)
+
+## v0.6.1 (2025-09-12)
+- fix: 解决系统参数关闭弹窗未清空的问题,编辑保时parameterId字段调整; (b51f408)
+
+## v0.6.0 (2025-09-12)
+- feat(dashboard): 新增智慧大屏功能模块 (95356ce)
+- feat: 告警历史新增设备id搜索与回显; (e54e7ae)
+- feat: 设备基础配置,安装方式设置为必填项; (eeeedc6)
+
+## v0.5.16 (2025-09-10)
+- feat: 调整告警计划交互联动,事件类型和生效时段联动; (26c3385)
+
+## v0.5.15 (2025-09-10)
+- feat: 设备列表最后离开时间改为活动状态;设备详情告警计划弹窗交互优化; (e708aba)
+- feat: 告警计划检测区域添加不可用提示; (b719fb5)
+
+## v0.5.14 (2025-09-10)
+- feat: 设备离线不允许保存设备信息(基础信息、区域信息、告警计划) (40d8106)
+
 ## v0.5.13 (2025-09-09)
 - feat: 添加告警计划的检测区域校验,删除冗余注释代码; (8581918)
 - feat: 隐藏注释 (df5d7fe)

+ 1 - 0
components.d.ts

@@ -12,6 +12,7 @@ declare module 'vue' {
     AAvatar: typeof import('ant-design-vue/es')['Avatar']
     ABadge: typeof import('ant-design-vue/es')['Badge']
     AButton: typeof import('ant-design-vue/es')['Button']
+    ACard: typeof import('ant-design-vue/es')['Card']
     ACascader: typeof import('ant-design-vue/es')['Cascader']
     ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
     ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "ln-web",
-  "version": "0.5.13",
+  "version": "0.6.2",
   "private": true,
   "type": "module",
   "scripts": {
@@ -34,6 +34,7 @@
     "axios": "^1.10.0",
     "dayjs": "^1.11.13",
     "echarts": "^5.6.0",
+    "echarts-liquidfill": "^3.1.0",
     "lodash-es": "^4.17.21",
     "mqtt": "^5.13.1",
     "nanoid": "^5.1.5",

+ 12 - 0
pnpm-lock.yaml

@@ -35,6 +35,9 @@ importers:
       echarts:
         specifier: ^5.6.0
         version: 5.6.0
+      echarts-liquidfill:
+        specifier: ^3.1.0
+        version: 3.1.0(echarts@5.6.0)
       lodash-es:
         specifier: ^4.17.21
         version: 4.17.21
@@ -1424,6 +1427,11 @@ packages:
   eastasianwidth@0.2.0:
     resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
 
+  echarts-liquidfill@3.1.0:
+    resolution: {integrity: sha512-5Dlqs/jTsdTUAsd+K5LPLLTgrbbNORUSBQyk8PSy1Mg2zgHDWm83FmvA4s0ooNepCJojFYRITTQ4GU1UUSKYLw==}
+    peerDependencies:
+      echarts: ^5.0.1
+
   echarts@5.6.0:
     resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
 
@@ -4323,6 +4331,10 @@ snapshots:
 
   eastasianwidth@0.2.0: {}
 
+  echarts-liquidfill@3.1.0(echarts@5.6.0):
+    dependencies:
+      echarts: 5.6.0
+
   echarts@5.6.0:
     dependencies:
       tslib: 2.3.0

+ 7 - 0
src/api/stats/index.ts

@@ -14,3 +14,10 @@ export const statsAlarmQuery = (
 ): Promise<ResponseData<TYPE.StatsAlarmQueryData>> => {
   return request.post('/stats/alarmEventsQuery', params)
 }
+
+// 首页大屏统计
+export const statsHomeScreenQuery = (params: {
+  tenantId: number
+}): Promise<ResponseData<TYPE.StatsHomeScreenQueryData>> => {
+  return request.post('/stats/screen', params)
+}

+ 28 - 0
src/api/stats/types.ts

@@ -105,3 +105,31 @@ export interface StatsAlarmQueryData {
   outTotalPageNum: boolean
   totalPageNum: number
 }
+
+type AgeList = {
+  ageRange: string // 年龄段
+  count: number // 数量
+}
+type GuardList = {
+  guardType: string // 监测对象类型
+  count: number // 数量
+}
+type InstallPositionList = {
+  installPosition: string // 	安装位置
+  count: number // 	安装数量
+}
+/**
+ * 首页大屏统计出参
+ */
+export interface StatsHomeScreenQueryData {
+  deviceCount: number // 设备总数
+  onlineCount: number // 在线设备数
+  systemGuardDay: number // 系统守护天数
+  fallingCount: number // 今日跌倒统计
+  alarmCount: number // 今日告警统计
+  detectedCount: number // 当天检测到人数
+  activeRate: number // 今日活跃度
+  ageList: AgeList[] // 年龄统计信息
+  guardList: GuardList[] // 守护统计信息
+  installPositionList: InstallPositionList[] // 安装位置统计信息
+}

+ 1 - 1
src/api/system/index.ts

@@ -19,7 +19,7 @@ export const queryRoleList = (): Promise<
  */
 
 export const saveSystemConfig = (params: {
-  paramId?: number | null
+  parameterId?: string | null
   paramCode: string
   paramName: string
   paramValue: string

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

@@ -34,6 +34,7 @@ type Props = {
 }
 const emit = defineEmits<{
   (e: 'update:open', value: boolean): void
+  (e: 'cancel'): void
 }>()
 
 const props = withDefaults(defineProps<Props>(), {
@@ -45,6 +46,7 @@ const props = withDefaults(defineProps<Props>(), {
 // 关闭弹窗
 const cancel = () => {
   emit('update:open', false)
+  emit('cancel')
 }
 </script>
 

+ 34 - 0
src/directives/disabled.ts

@@ -0,0 +1,34 @@
+import type { DirectiveBinding, ObjectDirective } from 'vue'
+
+const disabled: ObjectDirective = {
+  mounted(el: HTMLElement, binding: DirectiveBinding) {
+    updateDisabled(el, binding.value)
+  },
+  updated(el: HTMLElement, binding: DirectiveBinding) {
+    updateDisabled(el, binding.value)
+  },
+}
+
+function updateDisabled(el: HTMLElement, value: boolean) {
+  if (value) {
+    if ('disabled' in el) {
+      el.disabled = true
+    } else {
+      el.setAttribute('disabled', 'true')
+      el.style.pointerEvents = 'none'
+      el.style.opacity = '0.6'
+      el.style.cursor = 'not-allowed'
+    }
+  } else {
+    if ('disabled' in el) {
+      el.disabled = false
+    } else {
+      el.removeAttribute('disabled')
+      el.style.pointerEvents = ''
+      el.style.opacity = ''
+      el.style.cursor = ''
+    }
+  }
+}
+
+export default disabled

+ 15 - 0
src/layout/index.vue

@@ -28,6 +28,8 @@
       <a-layout-header>
         <slot name="header">
           <base-weather class="weather" mode="text"></base-weather>
+          <div class="smartScreen" @click="openSmartScreen">智慧大屏</div>
+
           <time-now class="timeNow"></time-now>
           <SyncOutlined class="refresh" @click="refresh" />
           <user-dropdown class="userDropdown"></user-dropdown>
@@ -364,6 +366,11 @@ const backHandler = async () => {
     await router.push(prevPath)
   }
 }
+
+// 打开智慧大屏
+const openSmartScreen = () => {
+  window.open('/dashboard', '_blank')
+}
 </script>
 
 <style scoped lang="less">
@@ -444,6 +451,14 @@ const backHandler = async () => {
     top: 0;
     z-index: 1000;
 
+    .smartScreen {
+      cursor: pointer;
+      color: #eee;
+      &:hover {
+        color: #85b8ff;
+      }
+    }
+
     .refresh {
       margin-right: 14px;
       font-size: 18px;

+ 2 - 0
src/main.ts

@@ -7,12 +7,14 @@ import '@/styles/index.css'
 import App from './App.vue'
 import router from './router'
 import pinia from '@/stores/index'
+import disabled from '@/directives/disabled'
 
 const app = createApp(App)
 
 app.config.globalProperties.$http = request
 app.use(pinia)
 app.use(router)
+app.directive('disabled', disabled)
 
 const components = import.meta.glob('./components/**/*.vue', { eager: true })
 Object.entries(components).forEach(([path, module]) => {

+ 6 - 0
src/router/index.ts

@@ -27,6 +27,12 @@ const router = createRouter({
       component: () => import('@/views/pointCloudMap/index.vue'),
       meta: { title: '设备点云图', isFullScreen: true, keepAlive: false, hidden: true },
     },
+    {
+      path: '/dashboard',
+      name: 'dashboard',
+      component: () => import('@/views/dashboard/index.vue'),
+      meta: { title: '大屏', isFullScreen: true, keepAlive: false, hidden: true },
+    },
   ],
 })
 router.beforeEach(authGuard)

+ 13 - 0
src/utils/useChartResize.ts

@@ -0,0 +1,13 @@
+import { onUnmounted } from 'vue'
+import * as echarts from 'echarts'
+
+export function useChartResize(chart: echarts.ECharts, container: HTMLElement) {
+  const observer = new ResizeObserver(() => {
+    chart.resize()
+  })
+  observer.observe(container)
+
+  onUnmounted(() => {
+    observer.disconnect()
+  })
+}

+ 141 - 0
src/utils/useResponsiveLayout.ts

@@ -0,0 +1,141 @@
+import { onMounted, onUnmounted, ref } from 'vue'
+import * as echarts from 'echarts'
+
+// 存储所有需要响应尺寸变化的图表实例
+const chartInstances: Map<string, echarts.ECharts> = new Map()
+let instanceCounter = 0
+
+// 当前窗口尺寸
+const windowSize = ref({
+  width: window.innerWidth,
+  height: window.innerHeight,
+})
+
+// 添加图表实例到监听列表
+export function addChartInstance(chart: echarts.ECharts) {
+  const id = `chart_${instanceCounter++}`
+  chartInstances.set(id, chart)
+  return id
+}
+
+// 从监听列表中移除图表实例
+export function removeChartInstance(id: string) {
+  const chart = chartInstances.get(id)
+  if (chart) {
+    chartInstances.delete(id)
+    try {
+      chart.dispose()
+    } catch (error) {
+      console.warn('Chart dispose failed:', error)
+    }
+  }
+}
+
+// 响应式布局钩子
+export function useResponsiveLayout() {
+  // 窗口尺寸变化处理函数
+  const handleResize = () => {
+    const newWidth = window.innerWidth
+    const newHeight = window.innerHeight
+
+    // 更新窗口尺寸状态
+    windowSize.value = {
+      width: newWidth,
+      height: newHeight,
+    }
+
+    // 使用requestAnimationFrame优化性能
+    requestAnimationFrame(() => {
+      // 调整所有图表大小
+      chartInstances.forEach((chart, id) => {
+        try {
+          chart.resize()
+        } catch (error) {
+          console.warn(`Chart resize failed for ${id}:`, error)
+          // 如果图表调整失败,尝试重新初始化
+          try {
+            const dom = chart.getDom()
+            if (dom) {
+              const newChart = echarts.init(dom)
+              chartInstances.set(id, newChart)
+              // 这里可以添加重新设置图表选项的逻辑
+            }
+          } catch (retryError) {
+            console.error(`Failed to reinitialize chart ${id}:`, retryError)
+          }
+        }
+      })
+    })
+  }
+
+  onMounted(() => {
+    // 监听窗口大小变化事件
+    window.addEventListener('resize', handleResize)
+    // 初始化时触发一次调整
+    handleResize()
+  })
+
+  onUnmounted(() => {
+    // 清理事件监听
+    window.removeEventListener('resize', handleResize)
+    // 清理所有图表实例
+    chartInstances.forEach((chart) => {
+      try {
+        chart.dispose()
+      } catch (error) {
+        console.warn('Chart dispose failed during cleanup:', error)
+      }
+    })
+    chartInstances.clear()
+  })
+
+  return {
+    windowSize,
+  }
+}
+
+// 导出响应式断点常量
+export const BREAKPOINTS = {
+  SMALL: 768,
+  MEDIUM: 1200,
+  LARGE: 1600,
+  XLARGE: 1920,
+}
+
+// 判断当前窗口是否为移动设备
+export function isMobile() {
+  return windowSize.value.width < BREAKPOINTS.SMALL
+}
+
+// 判断当前窗口是否为平板
+export function isTablet() {
+  return windowSize.value.width >= BREAKPOINTS.SMALL && windowSize.value.width < BREAKPOINTS.MEDIUM
+}
+
+// 判断当前窗口是否为桌面
+export function isDesktop() {
+  return windowSize.value.width >= BREAKPOINTS.MEDIUM
+}
+
+// 判断当前窗口是否为大屏幕
+export function isLargeScreen() {
+  return windowSize.value.width >= BREAKPOINTS.LARGE
+}
+
+// 判断当前窗口是否为超大屏幕
+export function isXLargeScreen() {
+  return windowSize.value.width >= BREAKPOINTS.XLARGE
+}
+
+// 强制调整所有图表大小
+export function forceResizeAllCharts() {
+  requestAnimationFrame(() => {
+    chartInstances.forEach((chart) => {
+      try {
+        chart.resize()
+      } catch (error) {
+        console.warn('Forced chart resize failed:', error)
+      }
+    })
+  })
+}

+ 7 - 0
src/views/alarm/history/const.ts

@@ -1,5 +1,12 @@
 export const columns = [
   {
+    title: '设备ID',
+    dataIndex: 'clientId',
+    key: 'createTime',
+    align: 'center',
+    width: 200,
+  },
+  {
     title: '发生时间',
     dataIndex: 'createTime',
     key: 'createTime',

+ 14 - 1
src/views/alarm/history/index.vue

@@ -2,6 +2,16 @@
   <div class="alarmHistoryPage">
     <div class="searchBar">
       <a-form layout="inline" @keydown.enter="searchHandler">
+        <a-form-item label="设备ID">
+          <a-input
+            v-model:value.trim="searchState.clientId"
+            placeholder="设备ID"
+            :maxlength="12"
+            show-count
+            allow-clear
+          />
+        </a-form-item>
+
         <a-form-item label="创建时间">
           <range-picker
             v-model:start="searchState.createTimeStart"
@@ -92,7 +102,7 @@
       :data="alarmPlanDataWithType"
       :area="{
         width: 400,
-        height: 400,
+        length: 400,
         ranges: [-200, 200, -200, 200],
       }"
       @success="fetchList"
@@ -124,6 +134,7 @@ interface SearchData {
   createTimeEnd: string // 创建时间结束
   type?: ID // 事件类型 一般滞留
   eventType?: ID // 事件类型 异常滞留
+  clientId?: ID // 设备ID
 }
 
 const defaultSearch: SearchData = {
@@ -131,6 +142,7 @@ const defaultSearch: SearchData = {
   createTimeEnd: '',
   type: null,
   eventType: null,
+  clientId: null,
 }
 
 const [searchState, resetHandler] = useSearch(defaultSearch, { afterReset: () => searchHandler() })
@@ -186,6 +198,7 @@ const fetchList = async () => {
       createTimeStart: searchState.createTimeStart,
       createTimeEnd: searchState.createTimeEnd,
       eventType: searchState.eventType as ID,
+      clientId: searchState.clientId,
     })
     console.log('✅ 获取告警统计数据成功', res)
     const { rows, total } = res.data

BIN
src/views/dashboard/assets/img/map.jpg


+ 89 - 0
src/views/dashboard/components/AlarmHistoryCard/index.vue

@@ -0,0 +1,89 @@
+<template>
+  <TechCard>
+    <div class="card-title">历史告警统计</div>
+    <div ref="chartRef" class="chart-container" />
+  </TechCard>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import * as echarts from 'echarts'
+import { useChartResize } from '@/utils/useChartResize'
+import TechCard from '../TechCard/index.vue'
+// import { alarmHistoryData } from '@/store/data'
+
+defineOptions({
+  name: 'AlarmHistoryCard',
+})
+
+const alarmHistoryData = ref({
+  dates: ['2024-07-01', '2024-07-02', '2024-07-03', '2024-07-04', '2024-07-05'],
+  values: [10, 15, 20, 12, 8],
+})
+const chartRef = ref<HTMLDivElement | null>(null)
+
+onMounted(() => {
+  if (!chartRef.value) return
+  const chart = echarts.init(chartRef.value)
+  chart.setOption({
+    grid: { top: 10, right: 20, bottom: 30, left: 30 },
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'shadow' },
+    },
+    xAxis: {
+      type: 'category',
+      data: alarmHistoryData.value.dates,
+      axisLine: { lineStyle: { color: '#2a3b5a' } },
+      axisLabel: { color: '#9cc5e0', fontSize: 12 },
+    },
+    yAxis: {
+      type: 'value',
+      axisLine: { lineStyle: { color: '#2a3b5a' } },
+      axisLabel: { color: '#9cc5e0', fontSize: 12 },
+      splitLine: { lineStyle: { color: 'rgba(42, 59, 90, 0.3)' } },
+    },
+    series: [
+      {
+        name: '告警次数',
+        type: 'line',
+        smooth: true,
+        symbol: 'circle',
+        symbolSize: 8,
+        data: alarmHistoryData.value.values,
+        itemStyle: { color: '#f39c12' },
+        lineStyle: {
+          width: 3,
+          color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
+            { offset: 0, color: '#f39c12' },
+            { offset: 1, color: '#e74c3c' },
+          ]),
+        },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: 'rgba(243, 156, 18, 0.3)' },
+            { offset: 1, color: 'rgba(243, 156, 18, 0.1)' },
+          ]),
+        },
+      },
+    ],
+  })
+
+  useChartResize(chart, chartRef.value)
+})
+</script>
+
+<style scoped>
+.card-title {
+  font-size: 16px;
+  font-weight: bold;
+  color: #f39c12;
+  margin-bottom: 12px;
+  text-align: center;
+}
+.chart-container {
+  width: 100%;
+  height: 100%;
+  min-height: 280px;
+}
+</style>

+ 85 - 0
src/views/dashboard/components/AlertFallCompareCard/index.vue

@@ -0,0 +1,85 @@
+<template>
+  <TechCard>
+    <div class="card-header">
+      <div class="title">跌倒与告警统计</div>
+    </div>
+    <div ref="chartRef" class="chart-container" />
+  </TechCard>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted } from 'vue'
+import * as echarts from 'echarts'
+import TechCard from '../TechCard/index.vue'
+
+defineOptions({ name: 'AlertFallCompareCard' })
+
+const chartRef = ref<HTMLDivElement | null>(null)
+const fallCount = 3
+const alertCount = 1
+
+onMounted(() => {
+  if (!chartRef.value) return
+  const chart = echarts.init(chartRef.value)
+
+  chart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'shadow' },
+      formatter: '{b}: {c} 次',
+    },
+    grid: {
+      left: 40,
+      right: 20,
+      top: 20,
+      bottom: 40,
+    },
+    xAxis: {
+      type: 'category',
+      data: ['跌倒', '告警'],
+      axisLabel: { color: '#9cc5e0' },
+      axisTick: { show: false },
+      axisLine: { show: false },
+    },
+    yAxis: {
+      type: 'value',
+      axisLabel: { color: '#9cc5e0' },
+      splitLine: { show: false },
+    },
+    series: [
+      {
+        type: 'bar',
+        data: [fallCount, alertCount],
+        barWidth: 40,
+        itemStyle: {
+          color: (params: { dataIndex: number }) => ['#ff4d6d', '#f39c12'][params.dataIndex],
+        },
+        label: {
+          show: true,
+          position: 'top',
+          color: '#fff',
+          fontSize: 14,
+        },
+      },
+    ],
+  })
+})
+</script>
+
+<style lang="less" scoped>
+.card-header {
+  text-align: center;
+  margin-bottom: 12px;
+
+  .title {
+    font-size: 16px;
+    font-weight: bold;
+    color: #00f0ff;
+  }
+}
+
+.chart-container {
+  width: 100%;
+  height: 200px;
+}
+</style>

+ 81 - 0
src/views/dashboard/components/DeviceAgeCard/index.vue

@@ -0,0 +1,81 @@
+<template>
+  <TechCard>
+    <div class="card-title">设备年龄层次分布</div>
+    <div ref="chartRef" class="chart-container" />
+  </TechCard>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import * as echarts from 'echarts'
+import { useChartResize } from '@/utils/useChartResize'
+import TechCard from '../TechCard/index.vue'
+
+defineOptions({
+  name: 'DeviceAgeCard',
+})
+
+const chartRef = ref<HTMLDivElement | null>(null)
+
+onMounted(() => {
+  if (!chartRef.value) return
+  const chart = echarts.init(chartRef.value)
+
+  chart.setOption({
+    tooltip: {
+      trigger: 'item',
+      appendToBody: true,
+      formatter: '{b}: {c}',
+    },
+    legend: {
+      orient: 'horizontal',
+      bottom: 10,
+      textStyle: {
+        color: '#fff',
+        fontSize: 12,
+      },
+    },
+    series: [
+      {
+        name: '设备年龄层次',
+        type: 'pie',
+        radius: ['20%', '70%'],
+        center: ['50%', '35%'],
+        roseType: 'area',
+        label: { show: false },
+        labelLine: { show: false },
+        data: [
+          { value: 15, name: '40-50岁' },
+          { value: 20, name: '50-60岁' },
+          { value: 40, name: '60-70岁' },
+          { value: 45, name: '70-80岁' },
+          { value: 50, name: '80岁以上' },
+        ],
+        itemStyle: {
+          color: (params: { dataIndex: number }) => {
+            const colors = ['#4dc9e6', '#2572ed', '#6de4ff', '#1a57c9', '#00bcd4']
+            return colors[params.dataIndex]
+          },
+        },
+      },
+    ],
+  })
+
+  useChartResize(chart, chartRef.value)
+})
+</script>
+
+<style scoped>
+.card-title {
+  font-size: 16px;
+  font-weight: bold;
+  color: #00f0ff;
+  margin-bottom: 12px;
+  text-align: center;
+}
+.chart-container {
+  width: 100%;
+  height: 100%;
+  min-height: 230px;
+}
+</style>

+ 86 - 0
src/views/dashboard/components/DeviceLocationCard/index.vue

@@ -0,0 +1,86 @@
+<template>
+  <TechCard>
+    <div class="card-title">设备安装位置分布</div>
+    <div ref="chartRef" class="chart-container" />
+  </TechCard>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import * as echarts from 'echarts'
+import { useChartResize } from '@/utils/useChartResize'
+import TechCard from '../TechCard/index.vue'
+
+defineOptions({
+  name: 'DeviceLocationCard',
+})
+
+const chartRef = ref<HTMLDivElement | null>(null)
+
+onMounted(() => {
+  if (!chartRef.value) return
+  const chart = echarts.init(chartRef.value)
+
+  chart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'shadow' },
+      formatter: '{b}: {c} 台',
+    },
+    grid: {
+      left: 60,
+      right: 20,
+      top: 20,
+      bottom: 20,
+    },
+    xAxis: {
+      type: 'value',
+      axisLabel: { color: '#9cc5e0' },
+      splitLine: { show: false },
+    },
+    yAxis: {
+      type: 'category',
+      data: ['卫生间', '卧室', '客厅', '餐厅'],
+      axisLabel: { color: '#9cc5e0' },
+      axisTick: { show: false },
+      axisLine: { show: false },
+    },
+    series: [
+      {
+        type: 'bar',
+        data: [12, 8, 6, 4],
+        barWidth: 14,
+        itemStyle: {
+          color: (params: { dataIndex: number }) => {
+            const colors = ['#4dc9e6', '#6de4ff', '#2572ed', '#1a57c9']
+            return colors[params.dataIndex]
+          },
+        },
+        label: {
+          show: true,
+          position: 'right',
+          color: '#00f0ff',
+          fontSize: 12,
+        },
+      },
+    ],
+  })
+
+  useChartResize(chart, chartRef.value)
+})
+</script>
+
+<style scoped>
+.card-title {
+  font-size: 16px;
+  font-weight: bold;
+  color: #00f0ff;
+  margin-bottom: 12px;
+  text-align: center;
+}
+.chart-container {
+  width: 100%;
+  height: 100%;
+  min-height: 150px;
+}
+</style>

+ 141 - 0
src/views/dashboard/components/DeviceOnlineRateCard/index.vue

@@ -0,0 +1,141 @@
+<template>
+  <TechCard>
+    <div class="card-header">
+      <div class="title">设备在线率</div>
+    </div>
+    <div ref="chartRef" class="chart-container"></div>
+    <div class="footer">
+      <div class="label">设备数量</div>
+      <div class="count">{{ onlineCount }} /1258 台</div>
+    </div>
+  </TechCard>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
+import * as echarts from 'echarts'
+import 'echarts-liquidfill'
+import TechCard from '../TechCard/index.vue'
+
+defineOptions({
+  name: 'DeviceOnlineRateCard',
+})
+
+const chartRef = ref<HTMLDivElement | null>(null)
+const chartInstance = ref<echarts.ECharts | null>(null)
+const onlineRate = ref(85.8)
+const onlineCount = 1284
+
+const timer = setInterval(() => {
+  onlineRate.value = Math.floor(Math.random() * 100)
+}, 1000)
+
+setTimeout(() => {
+  clearInterval(timer)
+}, 10000)
+
+const createChart = () => {
+  if (!chartRef.value) return
+  const chart = echarts.init(chartRef.value)
+  chartInstance.value = chart
+
+  chart.setOption({
+    series: [
+      {
+        type: 'liquidFill',
+        radius: '80%',
+        center: ['50%', '50%'],
+        data: [onlineRate.value / 100],
+        label: {
+          formatter: `${onlineRate.value}%`,
+          fontSize: 28,
+          fontWeight: 'bold',
+          color: '#00f0ff',
+        },
+        outline: {
+          show: true,
+          borderDistance: 4,
+          itemStyle: {
+            borderColor: '#00f0ff',
+            borderWidth: 2,
+          },
+        },
+        backgroundStyle: {
+          color: '#2c5364',
+        },
+        itemStyle: {
+          color: '#00f0ff',
+          opacity: 0.6,
+        },
+      },
+    ],
+  })
+}
+
+watch(onlineRate, (newVal) => {
+  if (chartInstance.value) {
+    chartInstance.value.setOption({
+      series: [
+        {
+          data: [newVal / 100],
+          label: {
+            formatter: `${newVal}%`,
+          },
+        },
+      ],
+    })
+  }
+})
+
+onMounted(() => {
+  nextTick(() => {
+    createChart()
+  })
+})
+
+onUnmounted(() => {
+  if (chartInstance.value) {
+    chartInstance.value.dispose()
+    chartInstance.value = null
+  }
+})
+</script>
+
+<style lang="less" scoped>
+.card-header {
+  text-align: center;
+  margin-bottom: 12px;
+
+  .title {
+    font-size: 16px;
+    font-weight: bold;
+    color: #00f0ff;
+  }
+}
+
+.chart-container {
+  width: 100%;
+  height: 160px;
+  min-height: 120px;
+  position: relative;
+}
+
+.footer {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-top: 12px;
+  font-size: 14px;
+  color: #9cc5e0;
+
+  .label {
+    margin-right: 8px;
+  }
+
+  .count {
+    font-weight: bold;
+    color: #00f0ff;
+    font-size: 16px;
+  }
+}
+</style>

+ 100 - 0
src/views/dashboard/components/ElderActivityCard/index.vue

@@ -0,0 +1,100 @@
+<template>
+  <TechCard>
+    <div class="card-header">
+      <div class="title">长者活跃度</div>
+    </div>
+    <div ref="chartRef" class="chart-container"></div>
+    <div class="footer">
+      <div class="label">当前活跃度</div>
+      <div class="count">{{ activityRate }}%</div>
+    </div>
+  </TechCard>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted } from 'vue'
+import * as echarts from 'echarts'
+import TechCard from '../TechCard/index.vue'
+
+defineOptions({
+  name: 'ElderActivityCard',
+})
+
+const chartRef = ref<HTMLDivElement | null>(null)
+const activityRate = 78
+
+onMounted(() => {
+  if (chartRef.value) {
+    const chart = echarts.init(chartRef.value)
+    chart.setOption({
+      tooltip: {
+        trigger: 'item',
+        appendToBody: true,
+        formatter: '{b}: {c}%',
+      },
+      series: [
+        {
+          type: 'pie',
+          radius: ['50%', '70%'],
+          center: ['50%', '50%'],
+          data: [
+            { value: activityRate, name: '活跃', itemStyle: { color: '#00f0ff' } },
+            { value: 100 - activityRate, name: '非活跃', itemStyle: { color: '#2c5364' } },
+          ],
+          label: { show: false },
+          labelLine: { show: false },
+        },
+      ],
+      graphic: {
+        type: 'text',
+        left: 'center',
+        top: 'center',
+        style: {
+          text: `${activityRate}%`,
+          fontSize: 28,
+          fontWeight: 'bold',
+          fill: '#00f0ff',
+          textShadow: '0 0 10px #00ff9f',
+        },
+      },
+    })
+  }
+})
+</script>
+
+<style lang="less" scoped>
+.card-header {
+  text-align: center;
+  margin-bottom: 12px;
+
+  .title {
+    font-size: 16px;
+    font-weight: bold;
+    color: #00f0ff;
+  }
+}
+
+.chart-container {
+  width: 100%;
+  height: 180px;
+}
+
+.footer {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-top: 12px;
+  font-size: 14px;
+  color: #9cc5e0;
+
+  .label {
+    margin-right: 8px;
+  }
+
+  .count {
+    font-weight: bold;
+    color: #00f0ff;
+    font-size: 16px;
+  }
+}
+</style>

+ 90 - 0
src/views/dashboard/components/FallingHistoryCard/index.vue

@@ -0,0 +1,90 @@
+<template>
+  <TechCard>
+    <div class="card-title">历史跌倒统计</div>
+    <div ref="chartRef" class="chart-container" />
+  </TechCard>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import * as echarts from 'echarts'
+import { useChartResize } from '@/utils/useChartResize'
+import TechCard from '../TechCard/index.vue'
+// import { fallingHistoryData } from '@/store/data'
+
+defineOptions({
+  name: 'FallingHistoryCard',
+})
+
+const fallingHistoryData = ref({
+  dates: ['2024-07-01', '2024-07-02', '2024-07-03', '2024-07-04', '2024-07-05'],
+  values: [5, 8, 6, 10, 7],
+})
+
+const chartRef = ref<HTMLDivElement | null>(null)
+
+onMounted(() => {
+  if (!chartRef.value) return
+  const chart = echarts.init(chartRef.value)
+  chart.setOption({
+    grid: { top: 10, right: 20, bottom: 30, left: 30 },
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'shadow' },
+    },
+    xAxis: {
+      type: 'category',
+      data: fallingHistoryData.value.dates,
+      axisLine: { lineStyle: { color: '#2a3b5a' } },
+      axisLabel: { color: '#9cc5e0', fontSize: 12 },
+    },
+    yAxis: {
+      type: 'value',
+      axisLine: { lineStyle: { color: '#2a3b5a' } },
+      axisLabel: { color: '#9cc5e0', fontSize: 12 },
+      splitLine: { lineStyle: { color: 'rgba(42, 59, 90, 0.3)' } },
+    },
+    series: [
+      {
+        name: '跌倒次数',
+        type: 'line',
+        smooth: true,
+        symbol: 'circle',
+        symbolSize: 8,
+        data: fallingHistoryData.value.values,
+        itemStyle: { color: '#e74c3c' },
+        lineStyle: {
+          width: 3,
+          color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
+            { offset: 0, color: '#e74c3c' },
+            { offset: 1, color: '#c0392b' },
+          ]),
+        },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: 'rgba(231, 76, 60, 0.3)' },
+            { offset: 1, color: 'rgba(231, 76, 60, 0.1)' },
+          ]),
+        },
+      },
+    ],
+  })
+
+  useChartResize(chart, chartRef.value)
+})
+</script>
+
+<style scoped>
+.card-title {
+  font-size: 16px;
+  font-weight: bold;
+  color: #e74c3c;
+  margin-bottom: 12px;
+  text-align: center;
+}
+.chart-container {
+  width: 100%;
+  height: 100%;
+  min-height: 280px;
+}
+</style>

+ 86 - 0
src/views/dashboard/components/ObjectDistributionCard/index.vue

@@ -0,0 +1,86 @@
+<template>
+  <TechCard>
+    <div class="card-title">检测对象分布</div>
+    <div ref="chartRef" class="chart-container" />
+  </TechCard>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import * as echarts from 'echarts'
+import { useChartResize } from '@/utils/useChartResize'
+import TechCard from '../TechCard/index.vue'
+
+defineOptions({
+  name: 'ObjectDistributionCard',
+})
+
+const chartRef = ref<HTMLDivElement | null>(null)
+
+onMounted(() => {
+  if (!chartRef.value) return
+  const chart = echarts.init(chartRef.value)
+
+  chart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'shadow' },
+      formatter: '{b}: {c} 个',
+    },
+    grid: {
+      left: 40,
+      right: 20,
+      top: 20,
+      bottom: 40,
+    },
+    xAxis: {
+      type: 'category',
+      data: ['重点', '一般'],
+      axisLabel: { color: '#9cc5e0' },
+      axisTick: { show: false },
+      axisLine: { show: false },
+    },
+    yAxis: {
+      type: 'value',
+      axisLabel: { color: '#9cc5e0' },
+      splitLine: { show: false },
+    },
+    series: [
+      {
+        type: 'bar',
+        data: [20, 45],
+        barWidth: 30,
+        itemStyle: {
+          color: (params: { dataIndex: number }) => {
+            const colors = ['#f39c12', '#2ecc71']
+            return colors[params.dataIndex]
+          },
+        },
+        label: {
+          show: true,
+          position: 'top',
+          color: '#00f0ff',
+          fontSize: 12,
+        },
+      },
+    ],
+  })
+
+  useChartResize(chart, chartRef.value)
+})
+</script>
+
+<style scoped>
+.card-title {
+  font-size: 16px;
+  font-weight: bold;
+  color: #00f0ff;
+  margin-bottom: 12px;
+  text-align: center;
+}
+.chart-container {
+  width: 100%;
+  height: 100%;
+  min-height: 200px;
+}
+</style>

+ 127 - 0
src/views/dashboard/components/PeopleDetectedCard/index.vue

@@ -0,0 +1,127 @@
+<template>
+  <TechCard>
+    <div class="card-header">
+      <div class="title">检测到人数</div>
+    </div>
+
+    <div class="matrix-wrapper">
+      <div class="scan-overlay" />
+      <div class="matrix-grid">
+        <div v-for="(item, index) in 12" :key="index" class="person-icon">
+          <svg viewBox="0 0 64 64" class="person-svg">
+            <circle cx="32" cy="12" r="6" />
+            <rect x="28" y="20" width="8" height="20" rx="4" />
+            <path d="M28 22 L20 32" />
+            <path d="M36 22 L44 32" />
+            <path d="M30 40 L26 52" />
+            <path d="M34 40 L38 52" />
+          </svg>
+        </div>
+      </div>
+    </div>
+
+    <div class="footer">
+      <div class="label">已检测人数</div>
+      <div class="count">{{ detectedCount }}人</div>
+    </div>
+  </TechCard>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import TechCard from '../TechCard/index.vue'
+
+defineOptions({ name: 'PeopleScanGlowCard' })
+
+const detectedCount = ref(8)
+</script>
+
+<style scoped lang="less">
+.card-header {
+  text-align: center;
+  margin-bottom: 12px;
+
+  .title {
+    font-size: 16px;
+    font-weight: bold;
+    color: #00f0ff;
+  }
+}
+
+.matrix-wrapper {
+  position: relative;
+  padding: 24px 12px;
+  overflow: hidden;
+}
+
+.scan-overlay {
+  position: absolute;
+  left: 0;
+  width: 100%;
+  height: 2px;
+  background: linear-gradient(to right, #00f0ff, #3b9ff0);
+  box-shadow: 0 0 6px #00f0ff;
+  pointer-events: none;
+  z-index: 1;
+  animation: scanDown 2.4s linear infinite;
+}
+
+@keyframes scanDown {
+  0% {
+    top: 0%;
+    opacity: 1;
+  }
+  90% {
+    opacity: 1;
+  }
+  100% {
+    top: 100%;
+    opacity: 0;
+  }
+}
+
+.matrix-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 1px;
+  justify-items: center;
+  position: relative;
+  z-index: 2;
+}
+
+.person-icon {
+  width: 36px;
+  height: 36px;
+
+  .person-svg {
+    width: 100%;
+    height: 100%;
+    stroke-width: 3.5;
+    fill: none;
+    stroke: #00ff9f;
+    transition:
+      transform 0.3s ease,
+      opacity 0.3s ease;
+    filter: drop-shadow(0 0 4px #00ff9f);
+  }
+}
+
+.footer {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-top: 12px;
+  font-size: 14px;
+  color: #9cc5e0;
+
+  .label {
+    margin-right: 8px;
+  }
+
+  .count {
+    font-weight: bold;
+    color: #00f0ff;
+    font-size: 16px;
+  }
+}
+</style>

+ 127 - 0
src/views/dashboard/components/TechCard/index.vue

@@ -0,0 +1,127 @@
+<template>
+  <div class="tech-card">
+    <div class="corner top-left"></div>
+    <div class="corner top-right"></div>
+    <div class="corner bottom-left"></div>
+    <div class="corner bottom-right"></div>
+
+    <div class="card-content">
+      <slot />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+defineOptions({ name: 'TechCard' })
+</script>
+
+<style lang="less" scoped>
+.tech-card {
+  position: relative;
+  background: linear-gradient(135deg, #001f3f, #005f99, #00aaff);
+  background: #0b173f;
+  border-radius: 12px;
+  padding: 24px;
+  color: #fff;
+  overflow: hidden;
+  transition: box-shadow 0.3s ease;
+  width: 100%; /* 确保卡片占满父容器 */
+  height: 100%; /* 确保卡片占满父容器 */
+  min-height: 200px; /* 确保卡片有最小高度 */
+
+  /* 响应式调整 */
+  @media (max-width: 1200px) {
+    padding: 20px;
+  }
+  
+  @media (max-width: 768px) {
+    padding: 15px;
+    min-height: 180px;
+  }
+
+  &::before {
+    content: '';
+    position: absolute;
+    inset: 0;
+    border-radius: 12px;
+    padding: 3px;
+    background: linear-gradient(270deg, #ff00cc, #3333ff, #00ffee, #ffcc00);
+    background-size: 600% 600%;
+    animation: rainbowBorder 6s linear infinite;
+    mask:
+      linear-gradient(#fff 0 0) content-box,
+      linear-gradient(#fff 0 0);
+    -webkit-mask:
+      linear-gradient(#fff 0 0) content-box,
+      linear-gradient(#fff 0 0);
+    mask-composite: exclude;
+    -webkit-mask-composite: destination-out;
+    opacity: 0.3;
+    transition: opacity 0.4s ease;
+    z-index: 10;
+    pointer-events: none;
+  }
+
+  &:hover::before {
+    opacity: 1;
+    padding: 5px;
+    animation: rainbowBorder 3s linear infinite;
+  }
+
+  .card-content {
+    position: relative;
+    z-index: 1;
+  }
+
+  .corner {
+    position: absolute;
+    width: 20px;
+    height: 20px;
+    z-index: 2;
+  }
+
+  .top-left {
+    top: 3px;
+    left: 3px;
+    border-top: 2px solid #00f0ff;
+    border-left: 2px solid #00f0ff;
+    border-radius: 6px 0 0 0;
+  }
+
+  .top-right {
+    top: 3px;
+    right: 3px;
+    border-top: 2px solid #00f0ff;
+    border-right: 2px solid #00f0ff;
+    border-radius: 0 6px 0 0;
+  }
+
+  .bottom-left {
+    bottom: 3px;
+    left: 3px;
+    border-bottom: 2px solid #00f0ff;
+    border-left: 2px solid #00f0ff;
+    border-radius: 0 0 0 6px;
+  }
+
+  .bottom-right {
+    bottom: 3px;
+    right: 3px;
+    border-bottom: 2px solid #00f0ff;
+    border-right: 2px solid #00f0ff;
+    border-radius: 0 0 6px 0;
+  }
+}
+
+@keyframes rainbowBorder {
+  0% {
+    background-position: 0% 50%;
+  }
+  50% {
+    background-position: 100% 50%;
+  }
+  100% {
+    background-position: 0% 50%;
+  }
+}
+</style>

+ 107 - 0
src/views/dashboard/components/dataCard/index.vue

@@ -0,0 +1,107 @@
+<template>
+  <div class="data-card">
+    <div class="tech-border-bottom-left"></div>
+    <div class="tech-border-bottom-right"></div>
+    <div class="data-card-title">{{ title }}</div>
+    <div class="data-card-value">{{ value }}</div>
+    <div class="data-card-change" :class="isPositive ? 'positive' : 'negative'">
+      {{ change }}
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { type DataCardProps } from '../../types/index'
+
+defineOptions({
+  name: 'DateCard',
+})
+
+withDefaults(defineProps<DataCardProps>(), {
+  isPositive: true,
+})
+</script>
+
+<style lang="less" scoped>
+.data-card {
+  background: linear-gradient(135deg, #0f2027, #203a43, #2c5364);
+  border-radius: 6px;
+  padding: 12px;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  overflow: hidden;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 16px;
+    height: 16px;
+    border-top: 2px solid #00f0ff;
+    border-left: 2px solid #00f0ff;
+    border-radius: 3px 0 0 0;
+  }
+  &::after {
+    content: '';
+    position: absolute;
+    top: 0;
+    right: 0;
+    width: 16px;
+    height: 16px;
+    border-top: 2px solid #00f0ff;
+    border-right: 2px solid #00f0ff;
+    border-radius: 0 3px 0 0;
+  }
+  & .tech-border-bottom-left {
+    content: '';
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    width: 16px;
+    height: 16px;
+    border-bottom: 2px solid #00f0ff;
+    border-left: 2px solid #00f0ff;
+    border-radius: 0 0 0 3px;
+  }
+  & .tech-border-bottom-right {
+    content: '';
+    position: absolute;
+    bottom: 0;
+    right: 0;
+    width: 16px;
+    height: 16px;
+    border-bottom: 2px solid #00f0ff;
+    border-right: 2px solid #00f0ff;
+    border-radius: 0 0 3px 0;
+  }
+}
+
+.data-card-title {
+  font-size: 14px;
+  color: #66e0ff;
+  margin-bottom: 8px;
+}
+
+.data-card-value {
+  font-size: 24px;
+  font-weight: bold;
+  color: #ffffff;
+  text-shadow: 0 0 12px #00f0ff;
+}
+
+.data-card-change {
+  font-size: 12px;
+  margin-top: 4px;
+  color: #a0faff;
+}
+
+.positive {
+  color: #00ff9f;
+}
+
+.negative {
+  color: #ff4d6d;
+}
+</style>

+ 832 - 0
src/views/dashboard/components/screen/index.vue

@@ -0,0 +1,832 @@
+<template>
+  <div class="radar-monitoring-screen">
+    <!-- 顶部区域 -->
+    <div class="header">
+      <div class="system-name">雷能社区智慧大屏</div>
+      <div class="running-days">已安全守护 {{ runningDays }} 天</div>
+      <div class="time-info">{{ currentTime }}</div>
+      <div class="data-flow header-flow"></div>
+    </div>
+
+    <!-- 内容区域 -->
+    <div class="content-area">
+      <!-- 左侧面板 -->
+      <div class="panel">
+        <div class="panel-title">今日监测概览</div>
+        <div class="panel-content">
+          <div class="data-grid">
+            <DataCard title="检测到人数" :value="todayData.detectedCount" />
+            <DataCard title="长者活跃度" :value="`${todayData.activeRate}%`" />
+            <DataCard title="跌倒统计次数" :value="todayData.alarmCount" />
+            <DataCard title="告警统计次数" :value="todayData.fallingCount" />
+            <DataCard
+              title="设备在线率"
+              :value="`${((todayData.onlineCount / todayData.deviceCount) * 100).toFixed(2)}%`"
+              :change="`${todayData.onlineCount} / ${todayData.deviceCount} 台`"
+            />
+          </div>
+
+          <div class="panel-title" style="margin-top: 15px">设备分布情况</div>
+          <div class="chart-container" ref="deviceChartRef"></div>
+
+          <div class="panel-title">设备年龄层次</div>
+          <div class="chart-container" ref="ageChartRef"></div>
+        </div>
+        <div class="data-flow"></div>
+        <div class="glow-effect"></div>
+      </div>
+
+      <!-- 中间面板 -->
+      <div class="panel center-panel">
+        <div class="map-container">
+          <img class="map-img" src="../../assets/img/map.jpg" alt="小区地图" />
+          <!-- <div class="map-label">雷能小区</div> -->
+          <div class="map-label building-1">1号楼</div>
+          <div class="map-label building-2">2号楼</div>
+          <div class="map-label building-3">物业中心</div>
+          <div class="map-label building-4">3号楼</div>
+        </div>
+      </div>
+
+      <!-- 右侧面板 -->
+      <div class="panel">
+        <div class="panel-title" style="margin-top: 15px">检测对象分布</div>
+        <div class="chart-container" ref="objectChartRef"></div>
+
+        <div class="panel-title" style="margin-top: 15px">历史告警统计</div>
+        <div class="chart-container" ref="alarmHistoryRef"></div>
+
+        <div class="panel-title" style="margin-top: 15px">历史跌倒统计</div>
+        <div class="chart-container" ref="fallingHistoryRef"></div>
+        <div class="data-flow"></div>
+        <div class="glow-effect"></div>
+      </div>
+    </div>
+
+    <!-- 底部区域 -->
+    <!-- <div class="footer">
+      <div>智慧养老雷达监控系统 &copy; 2023 版本 v2.5.1 | 技术支持:400-123-4567</div>
+    </div> -->
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onUnmounted } from 'vue'
+import * as echarts from 'echarts'
+import DataCard from '../dataCard/index.vue'
+import { type TodayData } from '../../types'
+
+defineOptions({
+  name: 'ScreenPage',
+})
+
+// 响应式数据
+const currentTime = ref('')
+const runningDays = ref(328)
+const todayData = ref<TodayData>({
+  deviceCount: 30,
+  onlineCount: 25,
+  systemGuardDay: 328,
+  alarmCount: 3,
+  fallingCount: 1,
+  detectedCount: 142,
+  activeRate: 78,
+})
+
+// 历史告警统计数据(最近7天)
+const alarmHistoryData = ref({
+  dates: ['1月1日', '1月2日', '1月3日', '1月4日', '1月5日', '1月6日', '1月7日'],
+  values: [3, 5, 2, 7, 4, 6, 3],
+})
+
+// 历史跌倒统计数据(最近7天)
+const fallingHistoryData = ref({
+  dates: ['1月1日', '1月2日', '1月3日', '1月4日', '1月5日', '1月6日', '1月7日'],
+  values: [1, 2, 0, 1, 3, 1, 0],
+})
+
+// 图表引用
+const deviceChartRef = ref<HTMLElement | null>(null)
+const ageChartRef = ref<HTMLElement | null>(null)
+const objectChartRef = ref<HTMLElement | null>(null)
+const alarmHistoryRef = ref<HTMLElement | null>(null)
+const fallingHistoryRef = ref<HTMLElement | null>(null)
+
+let deviceChart: echarts.ECharts | null = null
+let ageChart: echarts.ECharts | null = null
+let objectChart: echarts.ECharts | null = null
+let alarmHistoryChart: echarts.ECharts | null = null
+let fallingHistoryChart: echarts.ECharts | null = null
+const updateInterval: ReturnType<typeof setInterval> | null = null
+
+// 更新时间
+const updateTime = () => {
+  const now = new Date()
+  currentTime.value = now.toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit',
+    hour12: false,
+  })
+}
+
+// 初始化图表
+const initCharts = () => {
+  if (!deviceChartRef.value) return
+
+  // 设备分布情况图表(安装位置)
+  deviceChart = echarts.init(deviceChartRef.value)
+
+  deviceChart.setOption({
+    grid: { top: 10, right: 10, bottom: 10, left: 10 },
+    tooltip: {
+      trigger: 'item',
+      formatter: '{a} <br/>{b}: {c} ({d}%)',
+    },
+    series: [
+      {
+        name: '安装位置',
+        type: 'pie',
+        radius: ['20%', '70%'],
+        roseType: 'area',
+        itemStyle: {
+          borderColor: '#0a0e17',
+          borderWidth: 2,
+        },
+        label: {
+          show: true,
+          formatter: '{b}: {c}',
+          color: '#fff', // 设置文字颜色为白色
+          fontSize: 14,
+          fontWeight: 'bold',
+          textBorderColor: 'rgba(0, 0, 0, 0.8)', // 添加黑色描边
+          textBorderWidth: 2, // 描边宽度
+          textShadowColor: 'rgba(0, 0, 0, 0.5)', // 添加文字阴影
+          textShadowBlur: 4, // 阴影模糊程度
+          textShadowOffsetX: 1, // 阴影X偏移
+          textShadowOffsetY: 1, // 阴影Y偏移
+        },
+        emphasis: {
+          label: {
+            show: true,
+            fontWeight: 'bold',
+            fontSize: 16, // 放大强调时的文字
+          },
+        },
+        data: [
+          { value: 12, name: '卫生间', itemStyle: { color: '#4dc9e6' } },
+          { value: 8, name: '卧室', itemStyle: { color: '#6de4ff' } },
+          { value: 6, name: '客厅', itemStyle: { color: '#2572ed' } },
+          { value: 4, name: '餐厅', itemStyle: { color: '#1a57c9' } },
+        ],
+      },
+    ],
+  })
+
+  // 设备年龄层次图表
+  if (ageChartRef.value) {
+    ageChart = echarts.init(ageChartRef.value)
+    ageChart.setOption({
+      grid: { top: 10, right: 10, bottom: 10, left: 10 },
+      tooltip: {
+        trigger: 'item',
+        formatter: '{a} <br/>{b}: {c} ({d}%)',
+      },
+      series: [
+        {
+          name: '设备年龄',
+          type: 'pie',
+          radius: ['30%', '70%'],
+          itemStyle: {
+            borderColor: '#0a0e17',
+            borderWidth: 2,
+          },
+          label: {
+            show: true,
+            formatter: '{b}: {c}',
+            color: '#fff',
+            fontSize: 14,
+            fontWeight: 'bold',
+          },
+          emphasis: {
+            label: {
+              show: true,
+              fontWeight: 'bold',
+              fontSize: 16,
+            },
+          },
+          data: [
+            { value: 15, name: '1年内', itemStyle: { color: '#4dc9e6' } },
+            { value: 10, name: '1-2年', itemStyle: { color: '#2572ed' } },
+            { value: 5, name: '2-3年', itemStyle: { color: '#6de4ff' } },
+            { value: 2, name: '3年以上', itemStyle: { color: '#1a57c9' } },
+          ],
+        },
+      ],
+    })
+  }
+
+  // 检测对象图表
+  if (objectChartRef.value) {
+    objectChart = echarts.init(objectChartRef.value)
+    objectChart.setOption({
+      grid: { top: 10, right: 10, bottom: 10, left: 10 },
+      tooltip: {
+        trigger: 'item',
+        formatter: '{a} <br/>{b}: {c} ({d}%)',
+      },
+      series: [
+        {
+          name: '检测对象',
+          type: 'pie',
+          radius: ['30%', '70%'],
+          itemStyle: {
+            borderColor: '#0a0e17',
+            borderWidth: 2,
+          },
+          label: {
+            show: true,
+            formatter: '{b}: {c}',
+            color: '#fff',
+            fontSize: 14,
+            fontWeight: 'bold',
+          },
+          emphasis: {
+            label: {
+              show: true,
+              fontWeight: 'bold',
+              fontSize: 16,
+            },
+          },
+          data: [
+            { value: 45, name: '长者', itemStyle: { color: '#2ecc71' } },
+            { value: 20, name: '访客', itemStyle: { color: '#f39c12' } },
+            { value: 35, name: '工作人员', itemStyle: { color: '#e74c3c' } },
+          ],
+        },
+      ],
+    })
+  }
+
+  // 历史告警统计图表
+  if (alarmHistoryRef.value) {
+    alarmHistoryChart = echarts.init(alarmHistoryRef.value)
+    alarmHistoryChart.setOption({
+      grid: { top: 10, right: 20, bottom: 30, left: 30 },
+      tooltip: {
+        trigger: 'axis',
+        axisPointer: {
+          type: 'shadow',
+        },
+      },
+      xAxis: {
+        type: 'category',
+        data: alarmHistoryData.value.dates,
+        axisLine: {
+          lineStyle: {
+            color: '#2a3b5a',
+          },
+        },
+        axisLabel: {
+          color: '#9cc5e0',
+          fontSize: 12,
+        },
+      },
+      yAxis: {
+        type: 'value',
+        axisLine: {
+          lineStyle: {
+            color: '#2a3b5a',
+          },
+        },
+        axisLabel: {
+          color: '#9cc5e0',
+          fontSize: 12,
+        },
+        splitLine: {
+          lineStyle: {
+            color: 'rgba(42, 59, 90, 0.3)',
+          },
+        },
+      },
+      series: [
+        {
+          name: '告警次数',
+          type: 'line',
+          smooth: true,
+          symbol: 'circle',
+          symbolSize: 8,
+          data: alarmHistoryData.value.values,
+          itemStyle: {
+            color: '#f39c12',
+          },
+          lineStyle: {
+            width: 3,
+            color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
+              { offset: 0, color: '#f39c12' },
+              { offset: 1, color: '#e74c3c' },
+            ]),
+          },
+          areaStyle: {
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              { offset: 0, color: 'rgba(243, 156, 18, 0.3)' },
+              { offset: 1, color: 'rgba(243, 156, 18, 0.1)' },
+            ]),
+          },
+        },
+      ],
+    })
+  }
+
+  // 历史跌倒统计图表
+  if (fallingHistoryRef.value) {
+    fallingHistoryChart = echarts.init(fallingHistoryRef.value)
+    fallingHistoryChart.setOption({
+      grid: { top: 10, right: 20, bottom: 30, left: 30 },
+      tooltip: {
+        trigger: 'axis',
+        axisPointer: {
+          type: 'shadow',
+        },
+      },
+      xAxis: {
+        type: 'category',
+        data: fallingHistoryData.value.dates,
+        axisLine: {
+          lineStyle: {
+            color: '#2a3b5a',
+          },
+        },
+        axisLabel: {
+          color: '#9cc5e0',
+          fontSize: 12,
+        },
+      },
+      yAxis: {
+        type: 'value',
+        axisLine: {
+          lineStyle: {
+            color: '#2a3b5a',
+          },
+        },
+        axisLabel: {
+          color: '#9cc5e0',
+          fontSize: 12,
+        },
+        splitLine: {
+          lineStyle: {
+            color: 'rgba(42, 59, 90, 0.3)',
+          },
+        },
+      },
+      series: [
+        {
+          name: '跌倒次数',
+          type: 'line',
+          smooth: true,
+          symbol: 'circle',
+          symbolSize: 8,
+          data: fallingHistoryData.value.values,
+          itemStyle: {
+            color: '#e74c3c',
+          },
+          lineStyle: {
+            width: 3,
+            color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
+              { offset: 0, color: '#e74c3c' },
+              { offset: 1, color: '#c0392b' },
+            ]),
+          },
+          areaStyle: {
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              { offset: 0, color: 'rgba(231, 76, 60, 0.3)' },
+              { offset: 1, color: 'rgba(231, 76, 60, 0.1)' },
+            ]),
+          },
+        },
+      ],
+    })
+  }
+}
+
+// 更新图表数据
+// const updateChartData = () => {
+//   // 模拟数据更新
+//   todayData.value = {
+//     detected: Math.max(
+//       100,
+//       Math.min(200, todayData.value.detected + Math.floor(Math.random() * 10 - 5))
+//     ),
+//     activity: Math.max(
+//       60,
+//       Math.min(95, todayData.value.activity + Math.floor(Math.random() * 5 - 2))
+//     ),
+//     alerts: Math.max(0, Math.min(10, todayData.value.alerts + Math.floor(Math.random() * 3 - 1))),
+//     onlineRate: Math.max(
+//       90,
+//       Math.min(100, todayData.value.onlineRate + Math.floor(Math.random() * 3 - 1))
+//     ),
+//   }
+// }
+
+// 组件挂载时初始化
+onMounted(() => {
+  updateTime()
+  initCharts()
+
+  // 设置定时器
+  setInterval(updateTime, 1000)
+
+  // 移除对已注释函数的调用
+  // updateInterval = setInterval(updateChartData, 5000)
+
+  // 窗口大小变化时调整图表大小
+  const handleResize = () => {
+    // 调整安装位置图表大小
+    if (deviceChart && deviceChartRef.value) {
+      // 强制重新计算容器大小
+      const containerWidth = deviceChartRef.value.offsetWidth
+      const containerHeight = deviceChartRef.value.offsetHeight
+
+      // 确保有有效的尺寸
+      if (containerWidth > 0 && containerHeight > 0) {
+        deviceChart.resize()
+      }
+    }
+
+    // 调整设备年龄层次图表大小
+    if (ageChart && ageChartRef.value) {
+      const containerWidth = ageChartRef.value.offsetWidth
+      const containerHeight = ageChartRef.value.offsetHeight
+
+      if (containerWidth > 0 && containerHeight > 0) {
+        ageChart.resize()
+      }
+    }
+
+    // 调整检测对象图表大小
+    if (objectChart && objectChartRef.value) {
+      const containerWidth = objectChartRef.value.offsetWidth
+      const containerHeight = objectChartRef.value.offsetHeight
+
+      if (containerWidth > 0 && containerHeight > 0) {
+        objectChart.resize()
+      }
+    }
+
+    // 调整历史告警统计图表大小
+    if (alarmHistoryChart && alarmHistoryRef.value) {
+      const containerWidth = alarmHistoryRef.value.offsetWidth
+      const containerHeight = alarmHistoryRef.value.offsetHeight
+
+      if (containerWidth > 0 && containerHeight > 0) {
+        alarmHistoryChart.resize()
+      }
+    }
+
+    // 调整历史跌倒统计图表大小
+    if (fallingHistoryChart && fallingHistoryRef.value) {
+      const containerWidth = fallingHistoryRef.value.offsetWidth
+      const containerHeight = fallingHistoryRef.value.offsetHeight
+
+      if (containerWidth > 0 && containerHeight > 0) {
+        fallingHistoryChart.resize()
+      }
+    }
+  }
+
+  window.addEventListener('resize', handleResize)
+
+  // 组件挂载后立即触发一次调整,确保图表正确显示
+  setTimeout(() => {
+    handleResize()
+  }, 100)
+})
+
+// 组件卸载时清理
+onUnmounted(() => {
+  if (updateInterval) {
+    clearInterval(updateInterval)
+  }
+
+  if (deviceChart) {
+    deviceChart.dispose()
+  }
+
+  if (ageChart) {
+    ageChart.dispose()
+  }
+
+  if (objectChart) {
+    objectChart.dispose()
+  }
+
+  if (alarmHistoryChart) {
+    alarmHistoryChart.dispose()
+  }
+
+  if (fallingHistoryChart) {
+    fallingHistoryChart.dispose()
+  }
+})
+</script>
+
+<style lang="less" scoped>
+@bg-color: #22284a;
+@text-color: #e0e0e0;
+@panel-bg: #0b173f;
+@border-color: #2a3b5a;
+@primary-color: #4dc9e6;
+@secondary-color: #6de4ff;
+@accent-color: #2572ed;
+@success-color: #2ecc71;
+@warning-color: #f39c12;
+@danger-color: #e74c3c;
+@gradient-start: #181c41;
+@gradient-end: #22284a;
+@glow-color: rgba(77, 201, 230, 0.2);
+@data-flow-color: rgba(77, 201, 230, 0.7);
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+  font-family: 'Microsoft YaHei', Arial, sans-serif;
+}
+
+.radar-monitoring-screen {
+  background-color: @bg-color;
+  color: @text-color;
+  overflow: hidden;
+  height: 100vh;
+  padding: 12px;
+}
+
+/* 顶部区域 */
+.header {
+  height: 70px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 0 20px;
+  // background: linear-gradient(90deg, rgba(19, 28, 51, 0.8) 0%, rgba(32, 40, 65, 0.8) 100%);
+  background-color: @panel-bg;
+  border: 1px solid @border-color;
+  border-radius: 8px;
+  box-shadow: 0 0 15px rgba(0, 180, 255, 0.2);
+  margin-bottom: 12px;
+  position: relative;
+  overflow: hidden;
+}
+
+.system-name {
+  font-size: 24px;
+  background: linear-gradient(90deg, @primary-color, @secondary-color);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  text-shadow: 0 0 10px rgba(109, 228, 255, 0.5);
+  letter-spacing: 2px;
+}
+
+.running-days {
+  font-size: 22px;
+  color: @secondary-color;
+  text-shadow: 0 0 8px rgba(109, 228, 255, 0.7);
+}
+
+.time-info {
+  font-size: 16px;
+  color: @primary-color;
+}
+
+/* 内容区域 */
+.content-area {
+  display: flex;
+  height: calc(100vh - 110px);
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.panel {
+  flex: 1;
+  background: @panel-bg;
+  border: 1px solid @border-color;
+  border-radius: 8px;
+  padding: 15px;
+  box-shadow: 0 0 15px rgba(0, 180, 255, 0.1);
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  overflow: hidden;
+
+  &::after {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    height: 3px;
+    background: linear-gradient(90deg, @primary-color, @accent-color);
+  }
+}
+
+.panel-title {
+  font-size: 16px;
+  color: @secondary-color;
+  margin-bottom: 15px;
+  display: flex;
+  align-items: center;
+  padding-bottom: 8px;
+  border-bottom: 1px solid rgba(42, 59, 90, 0.5);
+
+  i {
+    margin-right: 8px;
+    font-size: 18px;
+  }
+}
+
+.panel-content {
+  flex: 1;
+  overflow: hidden;
+}
+
+.center-panel {
+  flex: 1.5;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background: #0b173f;
+  min-width: min-content;
+  height: 100%;
+  position: relative;
+  padding: 10px;
+}
+
+/* 地图容器 - 作为标记点的相对定位参考系 */
+.map-container {
+  position: relative;
+  display: inline-block;
+  // width: 100%;
+  // height: 100%;
+  // max-width: 600px;
+  // max-height: 400px;
+  border-radius: 6px;
+  overflow: hidden;
+  background-color: #2ecc71;
+  /* 确保容器有明确的宽高,以便内部元素可以基于百分比定位 */
+}
+
+/* 地图图片 - 确保图片始终完全显示在容器内 */
+.map-img {
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+  display: block;
+  border-radius: 6px;
+  /* 确保图片不会超出容器,同时保持原始比例 */
+}
+
+/* 地图标签基础样式 - 确保基于地图容器的相对定位 */
+.map-label {
+  position: absolute;
+  background: rgba(32, 40, 65, 0.9);
+  border: 1px solid @border-color;
+  border-radius: 4px;
+  padding: 4px 8px;
+  font-size: 14px;
+  color: @secondary-color;
+  font-weight: bold;
+  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
+  z-index: 10;
+  transform: translate(-50%, -50%);
+  animation: pulse 2s infinite;
+  pointer-events: none;
+}
+
+/* 小区名称特殊样式 */
+.map-label:not([class*='building-']) {
+  top: 10%;
+  left: 50%;
+  background: rgba(77, 201, 230, 0.9);
+  color: #000;
+  font-size: 18px;
+  padding: 6px 12px;
+  border: 2px solid #fff;
+  animation: glow 2s infinite alternate;
+}
+
+/* 建筑位置 - 使用百分比定位确保基于图片相对位置 */
+.building-1 {
+  top: 30%;
+  left: 30%;
+}
+
+.building-2 {
+  top: 50%;
+  left: 70%;
+}
+
+.building-3 {
+  top: 70%;
+  left: 50%;
+}
+
+.building-4 {
+  top: 40%;
+  left: 20%;
+}
+
+/* 位置标记动画效果 */
+@keyframes pulse {
+  0% {
+    transform: translate(-50%, -50%) scale(1);
+    box-shadow: 0 0 0 0 rgba(77, 201, 230, 0.4);
+  }
+  70% {
+    transform: translate(-50%, -50%) scale(1.05);
+    box-shadow: 0 0 0 5px rgba(77, 201, 230, 0);
+  }
+  100% {
+    transform: translate(-50%, -50%) scale(1);
+    box-shadow: 0 0 0 0 rgba(77, 201, 230, 0);
+  }
+}
+
+/* 小区名称发光效果 */
+@keyframes glow {
+  from {
+    box-shadow: 0 0 5px rgba(77, 201, 230, 0.7);
+  }
+  to {
+    box-shadow:
+      0 0 15px rgba(77, 201, 230, 1),
+      0 0 20px rgba(77, 201, 230, 0.7);
+  }
+}
+
+.data-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 12px;
+}
+
+.chart-container {
+  width: 100%;
+  height: 200px; /* 设置固定高度 */
+  min-height: 200px; /* 确保最小高度 */
+}
+
+.status-list {
+  list-style: none;
+  max-height: 250px;
+  overflow-y: auto;
+}
+
+/* 底部区域 */
+.footer {
+  height: 50px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background: @panel-bg;
+  border: 1px solid @border-color;
+  border-radius: 8px;
+  color: @primary-color;
+  font-size: 14px;
+}
+
+.glow-effect {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  pointer-events: none;
+  background:
+    radial-gradient(circle at 20% 30%, rgba(37, 114, 237, 0.1) 0%, transparent 50%),
+    radial-gradient(circle at 80% 70%, rgba(77, 201, 230, 0.1) 0%, transparent 50%);
+  z-index: -1;
+}
+
+.data-flow {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  width: 100%;
+  height: 3px;
+  background: linear-gradient(90deg, transparent, rgba(77, 201, 230, 0.7), transparent);
+  animation: dataFlow 3s linear infinite;
+}
+
+@keyframes dataFlow {
+  0% {
+    transform: translateX(-100%);
+  }
+  100% {
+    transform: translateX(100%);
+  }
+}
+</style>

+ 68 - 0
src/views/dashboard/components/statusItem/index.vue

@@ -0,0 +1,68 @@
+<template>
+  <li class="status-item">
+    <div class="status-name">
+      <span class="status-indicator" :class="statusClass"></span>
+      <span>{{ name }}</span>
+    </div>
+    <div class="status-value">{{ value }}</div>
+  </li>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { type StatusItemProps } from '../../types'
+
+defineOptions({
+  name: 'StatusItem',
+})
+
+const props = defineProps<StatusItemProps>()
+
+const statusClass = computed(() => {
+  return {
+    正常: 'normal',
+    警告: 'warning',
+    异常: 'danger',
+  }[props.status]
+})
+</script>
+
+<style lang="less" scoped>
+.status-item {
+  display: flex;
+  justify-content: space-between;
+  padding: 10px 0;
+  border-bottom: 1px solid rgba(42, 59, 90, 0.5);
+
+  &:last-child {
+    border-bottom: none;
+  }
+}
+
+.status-name {
+  display: flex;
+  align-items: center;
+}
+
+.status-indicator {
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  margin-right: 10px;
+
+  &.normal {
+    background-color: #2ecc71;
+    box-shadow: 0 0 8px #2ecc71;
+  }
+
+  &.warning {
+    background-color: #f39c12;
+    box-shadow: 0 0 8px #f39c12;
+  }
+
+  &.danger {
+    background-color: #e74c3c;
+    box-shadow: 0 0 8px #e74c3c;
+  }
+}
+</style>

+ 385 - 0
src/views/dashboard/index.vue

@@ -0,0 +1,385 @@
+<template>
+  <div class="dashboard">
+    <!-- <ScreenPage /> -->
+    <div class="dashboard-header">
+      <div class="community-name">雷能社区智慧大屏</div>
+      <div class="running-days">已安全守护 365 天</div>
+      <div class="time-info">{{ currentTime }}</div>
+      <div class="data-flow header-flow"></div>
+    </div>
+    <div class="dashboard-content">
+      <div class="block">
+        <div class="data-grid">
+          <DeviceOnlineRateCard></DeviceOnlineRateCard>
+          <PeopleDetectedCard></PeopleDetectedCard>
+          <!-- <ElderActivityCard></ElderActivityCard> -->
+          <ObjectDistributionCard></ObjectDistributionCard>
+          <AlertFallCompareCard></AlertFallCompareCard>
+        </div>
+        <DeviceLocationCard></DeviceLocationCard>
+      </div>
+      <div class="block block-center">
+        <div class="map-container">
+          <img class="map-img" src="./assets/img/map.jpg" alt="" />
+          <!-- <img class="map-img" src="./assets/img/map1.png" alt="" /> -->
+          <div class="map-label building-1">1号楼</div>
+          <div class="map-label building-2">2号楼</div>
+          <div class="map-label building-3">物业中心</div>
+          <div class="map-label building-4">3号楼</div>
+        </div>
+      </div>
+      <div class="block">
+        <div class="data-row">
+          <DeviceAgeCard></DeviceAgeCard>
+          <ElderActivityCard></ElderActivityCard>
+          <!-- <DetectionTargetCard></DetectionTargetCard> -->
+        </div>
+
+        <AlarmHistoryCard style="margin-bottom: 12px"></AlarmHistoryCard>
+        <FallingHistoryCard></FallingHistoryCard>
+      </div>
+    </div>
+    <div class="dashboard-footer">合肥雷能信息技术有限公司 © 2025 All Rights Reserved.</div>
+  </div>
+</template>
+
+<script setup lang="ts">
+// import ScreenPage from './components/screen/index.vue'
+import { ref, onMounted, onUnmounted } from 'vue'
+import DeviceOnlineRateCard from './components/DeviceOnlineRateCard/index.vue'
+import AlertFallCompareCard from './components/AlertFallCompareCard/index.vue'
+import ElderActivityCard from './components/ElderActivityCard/index.vue'
+import PeopleDetectedCard from './components/PeopleDetectedCard/index.vue'
+import DeviceLocationCard from './components/DeviceLocationCard/index.vue'
+import DeviceAgeCard from './components/DeviceAgeCard/index.vue'
+import ObjectDistributionCard from './components/ObjectDistributionCard/index.vue'
+import AlarmHistoryCard from './components/AlarmHistoryCard/index.vue'
+import FallingHistoryCard from './components/FallingHistoryCard/index.vue'
+
+defineOptions({
+  name: 'DashboardPage',
+})
+
+// 使用响应式布局工具
+
+// 格式化当前时间
+const currentTime = ref('')
+const formatTime = () => {
+  const now = new Date()
+  const year = now.getFullYear()
+  const month = String(now.getMonth() + 1).padStart(2, '0')
+  const day = String(now.getDate()).padStart(2, '0')
+  const hours = String(now.getHours()).padStart(2, '0')
+  const minutes = String(now.getMinutes()).padStart(2, '0')
+  const seconds = String(now.getSeconds()).padStart(2, '0')
+  currentTime.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
+}
+
+onMounted(() => {
+  formatTime()
+  // 每秒更新一次时间
+  const timeInterval = setInterval(formatTime, 1000)
+
+  // 组件卸载时清除定时器
+  onUnmounted(() => {
+    clearInterval(timeInterval)
+  })
+})
+
+// const todayData = ref<TodayData>({
+//   deviceCount: 30,
+//   onlineCount: 25,
+//   systemGuardDay: 328,
+//   alarmCount: 3,
+//   fallingCount: 1,
+//   detectedCount: 142,
+//   activeRate: 78,
+// })
+</script>
+
+<style scoped lang="less">
+@bg-color: #22284a;
+@text-color: #e0e0e0;
+@panel-bg: #0b173f;
+@border-color: #2a3b5a;
+@primary-color: #4dc9e6;
+@secondary-color: #6de4ff;
+@accent-color: #2572ed;
+@success-color: #2ecc71;
+@warning-color: #f39c12;
+@danger-color: #e74c3c;
+@gradient-start: #181c41;
+@gradient-end: #22284a;
+@glow-color: rgba(77, 201, 230, 0.2);
+@data-flow-color: rgba(77, 201, 230, 0.7);
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+  font-family: 'Microsoft YaHei', Arial, sans-serif;
+}
+
+.dashboard {
+  background-color: @bg-color;
+  color: @text-color;
+  height: 100vh;
+  width: 100vw;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+
+  &-header {
+    height: 70px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 0 20px;
+    background-color: @panel-bg;
+    border: 1px solid @border-color;
+    border-radius: 8px;
+    box-shadow: 0 0 15px rgba(0, 180, 255, 0.2);
+    margin-bottom: 12px;
+    position: relative;
+    overflow: hidden;
+    flex-wrap: wrap;
+
+    .community-name {
+      font-size: 24px;
+      background: linear-gradient(90deg, @primary-color, @secondary-color);
+      -webkit-background-clip: text;
+      background-clip: text;
+      -webkit-text-fill-color: transparent;
+      text-shadow: 0 0 10px rgba(109, 228, 255, 0.5);
+      letter-spacing: 2px;
+      flex-shrink: 0;
+    }
+
+    .running-days {
+      font-size: 22px;
+      color: @secondary-color;
+      text-shadow: 0 0 8px rgba(109, 228, 255, 0.7);
+      flex-shrink: 0;
+    }
+
+    .time-info {
+      font-size: 18px;
+      color: @primary-color;
+      flex-shrink: 0;
+    }
+
+    .data-flow {
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      width: 100%;
+      height: 3px;
+      background: linear-gradient(90deg, transparent, rgba(77, 201, 230, 0.7), transparent);
+      animation: dataFlow 2s linear infinite;
+    }
+
+    @keyframes dataFlow {
+      0% {
+        transform: translateX(-100%);
+      }
+      100% {
+        transform: translateX(100%);
+      }
+    }
+  }
+
+  &-content {
+    padding: 24px;
+    flex: 1;
+    border-radius: 8px;
+    display: flex;
+    gap: 20px;
+    overflow: hidden;
+    min-height: 0;
+
+    .block {
+      min-height: 500px;
+      flex-grow: 1;
+      flex-shrink: 1;
+      flex: 1 1 300px;
+      display: flex;
+      flex-direction: column;
+      position: relative;
+      overflow: hidden;
+    }
+
+    .block-center {
+      flex: 1 1 600px;
+      display: flex;
+      min-width: min-content;
+      position: relative;
+
+      .map-container {
+        position: relative;
+        display: inline-block;
+        border-radius: 6px;
+        height: 100%;
+        width: 100%;
+        background: #0b173f;
+        overflow: hidden;
+        user-select: none;
+      }
+
+      .map-img {
+        width: 100%;
+        height: 100%;
+        object-fit: contain;
+        display: block;
+        border-radius: 6px;
+        pointer-events: none;
+      }
+
+      .map-label {
+        position: absolute;
+        background: rgba(32, 40, 65, 0.9);
+        border: 1px solid @border-color;
+        border-radius: 4px;
+        padding: 4px 8px;
+        font-size: 14px;
+        color: @secondary-color;
+        font-weight: bold;
+        text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
+        z-index: 10;
+        transform: translate(-50%, -50%);
+        animation: pulse 2s infinite;
+        pointer-events: none;
+      }
+
+      .map-label:not([class*='building-']) {
+        top: 10%;
+        left: 50%;
+        background: rgba(77, 201, 230, 0.9);
+        color: #000;
+        font-size: 18px;
+        padding: 6px 12px;
+        border: 2px solid #fff;
+        animation: glow 2s infinite alternate;
+      }
+
+      .building-1 {
+        top: 30%;
+        left: 30%;
+      }
+
+      .building-2 {
+        top: 50%;
+        left: 70%;
+      }
+
+      .building-3 {
+        top: 70%;
+        left: 50%;
+      }
+
+      .building-4 {
+        top: 40%;
+        left: 20%;
+      }
+
+      @keyframes pulse {
+        0% {
+          transform: translate(-50%, -50%) scale(1);
+          box-shadow: 0 0 0 0 rgba(77, 201, 230, 0.4);
+        }
+        70% {
+          transform: translate(-50%, -50%) scale(1.05);
+          box-shadow: 0 0 0 5px rgba(77, 201, 230, 0);
+        }
+        100% {
+          transform: translate(-50%, -50%) scale(1);
+          box-shadow: 0 0 0 0 rgba(77, 201, 230, 0);
+        }
+      }
+    }
+
+    .data-grid {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 12px;
+      margin-bottom: 12px;
+    }
+
+    .data-row {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 12px;
+      margin-bottom: 12px;
+    }
+  }
+
+  &-footer {
+    height: 20px;
+    color: @text-color;
+    color: #6de4ff;
+    text-align: center;
+    line-height: 30px;
+    font-size: 12px;
+    margin-bottom: 10px;
+  }
+
+  @media (max-width: 1600px) {
+    .dashboard-header .community-name {
+      font-size: 20px;
+    }
+
+    .dashboard-header .running-days {
+      font-size: 18px;
+    }
+
+    .dashboard-header .time-info {
+      font-size: 16px;
+    }
+  }
+
+  @media (max-width: 1200px) {
+    .dashboard-content {
+      flex-direction: column;
+      gap: 15px;
+    }
+
+    .dashboard-content .block {
+      min-height: 400px;
+    }
+
+    .dashboard-content .data-grid,
+    .dashboard-content .data-row {
+      grid-template-columns: 1fr;
+    }
+  }
+
+  @media (max-width: 768px) {
+    .dashboard-header {
+      height: auto;
+      padding: 15px;
+      flex-direction: column;
+      gap: 10px;
+      text-align: center;
+    }
+
+    .dashboard-header .community-name,
+    .dashboard-header .running-days,
+    .dashboard-header .time-info {
+      font-size: 16px;
+    }
+
+    .dashboard-content {
+      padding: 15px;
+      min-height: calc(100vh - 150px - 50px);
+    }
+
+    .dashboard-content .block {
+      min-height: 300px;
+    }
+
+    .map-label {
+      font-size: 12px !important;
+      padding: 2px 6px !important;
+    }
+  }
+}
+</style>

+ 25 - 0
src/views/dashboard/types/index.ts

@@ -0,0 +1,25 @@
+import type { StatsHomeScreenQueryData } from '@/api/stats/types'
+
+export interface DataCardProps {
+  title: string
+  value: string | number
+  change?: string | number
+  isPositive?: boolean
+}
+
+export interface StatusItemProps {
+  name: string
+  value: string
+  status: '正常' | '警告' | '异常'
+}
+
+export type TodayData = Pick<
+  StatsHomeScreenQueryData,
+  | 'deviceCount'
+  | 'onlineCount'
+  | 'systemGuardDay'
+  | 'fallingCount'
+  | 'alarmCount'
+  | 'detectedCount'
+  | 'activeRate'
+>

+ 52 - 5
src/views/device/detail/components/alarmPlanModal/index.vue

@@ -15,7 +15,7 @@
         :label-col="{ style: { width: '80px' } }"
         hideRequiredMark
       >
-        <a-form-item v-if="props.type === 'plan'" label="计划模板" name="planName">
+        <a-form-item v-if="props.type === 'plan'" label="计划模板">
           <div style="display: flex; align-items: center; gap: 8px">
             <a-select
               v-model:value="planTemplateId"
@@ -56,7 +56,7 @@
 
         <a-form-item
           label="计划备注"
-          name="planName"
+          name="remark"
           :rules="[{ required: true, message: '请输入计划备注' }]"
         >
           <a-input
@@ -116,6 +116,7 @@
             v-model:value="formState.eventType"
             :options="eventTypeList"
             placeholder="请选择事件类型"
+            @change="eventTypeChange"
           />
 
           <a-form-item-rest v-if="formState.eventType && ![1, 2, 3].includes(formState.eventType)">
@@ -198,8 +199,15 @@
               valueFormat="HH:mm"
               format="HH:mm"
               style="width: 100%"
+              :disabled="[4, 6, 8].includes(formState.eventType!)"
             />
-            <a-button size="small" type="link" @click="addEffectTime">添加</a-button>
+            <a-button
+              size="small"
+              type="link"
+              :disabled="[4, 6, 8].includes(formState.eventType!)"
+              @click="addEffectTime"
+              >添加</a-button
+            >
           </div>
           <div style="margin-top: 12px">
             <span
@@ -247,7 +255,13 @@
           name="region"
           style="user-select: none"
         >
-          <div>雷达检测区域:{{ props.area!.ranges }}</div>
+          <a-alert
+            v-if="areaAvailable"
+            message="检测区域范围未配置或数值较小,请在设备配置调整参数!"
+            banner
+            style="margin-bottom: 10px"
+          />
+          <div> 雷达检测区域:{{ props.area!.ranges }} </div>
           <div style="user-select: none">
             <span>告警框选区域: {{ formState.region }}</span>
             <a-button type="link" style="margin-left: 16px" @click="resetBlocks"> 重置 </a-button>
@@ -761,7 +775,10 @@ const echoFormState = (val: SourceData) => {
     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[],
+    statisticsTime:
+      paramObj?.start_time && paramObj?.end_time
+        ? ([paramObj.start_time, paramObj.end_time] as string[])
+        : [],
     count: paramObj.count ?? null,
     timeThreshold: paramObj.time_threshold ?? null,
     planTime: planTimes,
@@ -846,6 +863,11 @@ const addEffectTime = () => {
     message.warn('请选择时间段')
     return
   }
+  // 仅允许添加一个时段校验
+  if ([5, 7].includes(formState.eventType!) && formState.effectTimeFrames.length >= 1) {
+    message.warn('当前事件类型,仅支持添加一个生效时段')
+    return
+  }
   formState.effectTimeFrames.push({
     startTime: formState.effectTimeFrame[0],
     endTime: formState.effectTimeFrame[1],
@@ -858,6 +880,20 @@ const deleteEffectTimeItem = (e: Event, index: number) => {
   formState.effectTimeFrames.splice(index, 1)
 }
 
+// 生效时段变化
+const eventTypeChange = (value: number) => {
+  if ([4, 6, 8].includes(value)) {
+    formState.effectTimeFrames = [
+      {
+        startTime: '00:00',
+        endTime: '23:59',
+      },
+    ]
+  } else {
+    formState.effectTimeFrames = []
+  }
+}
+
 // 关闭弹窗
 const cancel = () => {
   formRef?.value?.resetFields()
@@ -959,6 +995,11 @@ const submit = () => {
         console.log('🔥paramData🔥', paramData)
       }
 
+      if ([5, 7].includes(formState.eventType!) && formState.effectTimeFrames.length >= 1) {
+        message.warn('当前事件类型,仅支持添加一个生效时段')
+        return
+      }
+
       const params = {
         alarmPlanId: props.alarmPlanId || undefined, // 告警计划ID 编辑时传入
         clientId: props.clientId, // 设备ID
@@ -1108,6 +1149,12 @@ const fetchRoomLayout = async () => {
   }
 }
 fetchRoomLayout()
+
+const areaAvailable = computed(() => {
+  const length = Math.abs(props.area!.ranges[0]) + Math.abs(props.area!.ranges[1])
+  const width = Math.abs(props.area!.ranges[2]) + Math.abs(props.area!.ranges[3])
+  return Number(length) < 50 || Number(width) < 50
+})
 </script>
 
 <style scoped lang="less">

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

@@ -22,10 +22,12 @@
         </div>
         <div class="viewer-header-extra">
           <a-space>
+            <span v-if="props.online === 0" style="color: red">⚠️设备离线,不允许编辑保存</span>
             <a-switch
               :checked="isEditDraggable"
               checked-children="启用"
               un-checked-children="禁用"
+              :disabled="props.online === 0"
               @change="isEditDraggable = !isEditDraggable"
             />
             <a-button
@@ -379,6 +381,7 @@ type Props = {
   length: number
   width: number
   ranges: number[] // 区域范围
+  online?: SwitchType // 设备在线状态,用于判断是否可以保存配置
 }
 const emit = defineEmits<{
   (e: 'success', value: void): void
@@ -388,6 +391,7 @@ const props = withDefaults(defineProps<Props>(), {
   length: 0, // 区域宽度
   width: 0, // 区域高度
   ranges: () => [], // 区域范围
+  online: 0,
 })
 
 const deviceRoomId = ref('')

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

@@ -213,9 +213,14 @@
 
       <div class="footer" :style="{ marginLeft: '100px' }">
         <a-space>
-          <a-button type="primary" :loading="saveBaseLoading" @click="saveBaseConfig"
+          <a-button
+            type="primary"
+            :loading="saveBaseLoading"
+            :disabled="props.online === 0"
+            @click="saveBaseConfig"
             >保存配置</a-button
           >
+          <span v-if="props.online === 0" style="color: red">⚠️设备离线,不允许编辑保存</span>
         </a-space>
       </div>
     </a-form>
@@ -239,6 +244,7 @@ defineOptions({
 type Props = {
   devId: string // 设备id 查询使用
   clientId: string // 设备id 更新使用
+  online: SwitchType // 设备是否在线
 }
 const emit = defineEmits<{
   (e: 'success', value: void): void
@@ -246,6 +252,7 @@ const emit = defineEmits<{
 const props = withDefaults(defineProps<Props>(), {
   devId: '',
   clientId: '',
+  online: 0,
 })
 
 const userStore = useUserStore()
@@ -369,6 +376,13 @@ const saveBaseConfig = async () => {
 
 // 统一校验规则
 const rules: Record<string, Rule[]> = {
+  installWay: [
+    {
+      required: true,
+      message: '请选择安装方式',
+      trigger: ['change', 'blur'],
+    },
+  ],
   // xRangeStart: [
   //   {
   //     required: true,

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

@@ -19,6 +19,7 @@
       <deviceBaseConfig
         :dev-id="props.data.devId"
         :client-id="props.data.clientId"
+        :online="props.online"
         @success="deviceAreaConfigSuccess"
       ></deviceBaseConfig>
     </template>
@@ -29,6 +30,7 @@
         :length="props.data.length"
         :width="props.data.width"
         :ranges="[props.data.xStart, props.data.xEnd, props.data.yStart, props.data.yEnd]"
+        :online="props.online"
         @success="deviceAreaConfigSuccess"
       ></deviceAreaConfig>
     </template>
@@ -58,6 +60,7 @@ type Props = {
     yStart: number
     yEnd: number
   }
+  online?: SwitchType
 }
 
 const emit = defineEmits<{
@@ -78,6 +81,7 @@ const props = withDefaults(defineProps<Props>(), {
     yStart: 0,
     yEnd: 0,
   }),
+  online: 0,
 })
 
 const modeRadio = ref<'base' | 'area'>(props.mode)

+ 29 - 6
src/views/device/detail/index.vue

@@ -173,7 +173,7 @@
           <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.hardware }}</info-item>
           <info-item label="激活日期">{{ detailState.activeTime }}</info-item>
           <info-item label="在离线状态">
             <template v-if="detailState.clientId">
@@ -230,7 +230,9 @@
         <info-item-group title="告警计划" class="alarmPlanGroup">
           <template #extra>
             <a-space>
-              <a-button type="primary" size="small" @click="addPlanHandler"> 新增计划 </a-button>
+              <a-button type="primary" size="small" :disabled="!isOnline" @click="addPlanHandler">
+                新增计划
+              </a-button>
             </a-space>
           </template>
 
@@ -243,24 +245,34 @@
                   /></div>
                   <div class="alarmPlan-item-contant" :title="plan.name">{{ plan.name }}</div>
                   <div class="alarmPlan-item-action">
-                    <a-space>
+                    <a-space :class="!isOnline && 'offline'">
                       <a-popconfirm
                         :title="`确认${plan.enable ? '禁用' : '启用'}计划吗?`"
                         ok-text="确认"
                         cancel-text="取消"
+                        :disabled="!isOnline"
                         @confirm="swtichAlarmItem(plan.id, plan.enable, plan)"
                       >
-                        <a-switch :checked="plan.enable" size="small" :loading="plan.loading" />
+                        <a-switch
+                          :checked="plan.enable"
+                          size="small"
+                          :loading="plan.loading"
+                          :disabled="!isOnline"
+                        />
                       </a-popconfirm>
 
-                      <EditOutlined @click="editAlarmItem(plan.data as AlarmPlanItem)" />
+                      <EditOutlined
+                        v-disabled="!isOnline"
+                        @click="editAlarmItem(plan.data as AlarmPlanItem)"
+                      />
                       <a-popconfirm
                         title="确认删除计划吗?"
                         ok-text="确认"
                         cancel-text="取消"
+                        :disabled="!isOnline"
                         @confirm="deleteAlarmItem(plan.id)"
                       >
-                        <DeleteOutlined />
+                        <DeleteOutlined v-disabled="!isOnline" />
                       </a-popconfirm>
                     </a-space>
                   </div>
@@ -290,6 +302,7 @@
         :room-id="deviceRoomId"
         :furniture-items="furnitureItems"
         :sub-region-items="subRegionItems"
+        :online="detailState.online"
         @success="saveConfigSuccess"
       ></deviceConfigDrawer>
 
@@ -664,6 +677,9 @@ const areaAvailable = computed(() => {
   return Number(length) < 50 || Number(width) < 50
 })
 
+// 设备是否在线
+const isOnline = computed(() => detailState.value.online === 1)
+
 onUnmounted(() => {
   if (mqttClient) mqttClient.end()
   if (mqttTimeout) clearTimeout(mqttTimeout)
@@ -945,6 +961,13 @@ const swtichAlarmItem = async (id: number, swtich: boolean, item: AlarmPlanItem)
           color: #40a9ff;
         }
       }
+
+      :deep(.ant-space.offline) {
+        cursor: not-allowed;
+        .ant-space-item:hover {
+          color: #888;
+        }
+      }
     }
   }
 }

+ 1 - 1
src/views/device/list/components/addDevice/index.vue

@@ -134,7 +134,7 @@ const submit = () => {
         .addDevice({
           clientId: formState.deviceId,
           devType: formState.deviceType,
-          software: formState.firmwareVersion,
+          hardware: formState.firmwareVersion,
           tenantId: formState.tenantId,
         })
         .then((res) => {

+ 3 - 3
src/views/device/list/const.ts

@@ -22,9 +22,9 @@ export const columns = [
     align: 'center',
   },
   {
-    title: '最后离开时间',
-    key: 'lastTargetTime',
-    dataIndex: 'lastTargetTime',
+    title: '活动状态',
+    key: 'activeState',
+    dataIndex: 'activeState',
     align: 'center',
     width: 200,
   },

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

@@ -76,6 +76,12 @@
             <a-tag v-if="record.online === 0" :bordered="false" color="red">离线</a-tag>
             <a-tag v-if="record.online === 1" :bordered="false" color="green">在线</a-tag>
           </template>
+          <template v-if="column.key === 'activeState'">
+            <a-tag v-if="isToday(record.presenceChangeTime)" :bordered="false" color="green"
+              >有</a-tag
+            >
+            <a-tag v-else :bordered="false" color="#ccc">无</a-tag>
+          </template>
           <template v-if="column.key === 'action'">
             <a-button type="link" @click="detailHandler(record.devId, record.clientId)"
               >查看详情</a-button
@@ -395,6 +401,18 @@ const uploadDeviceHandler = () => {
   console.log('批量上传设备')
   uploadDeviceOpen.value = true
 }
+
+// 是否为今天
+function isToday(dateStr: string): boolean {
+  const inputDate = new Date(dateStr)
+  const now = new Date()
+
+  return (
+    inputDate.getFullYear() === now.getFullYear() &&
+    inputDate.getMonth() === now.getMonth() &&
+    inputDate.getDate() === now.getDate()
+  )
+}
 </script>
 
 <style scoped lang="less">

+ 3 - 2
src/views/system/config/index.vue

@@ -39,7 +39,7 @@ vabse
       </info-item-group>
     </info-card>
 
-    <baseModal v-model:open="isOpenConfig" title="新增参数">
+    <baseModal v-model:open="isOpenConfig" title="新增参数" @cancel="cancel">
       <a-form
         ref="formRef"
         :model="formState"
@@ -173,11 +173,12 @@ const cancel = () => {
 
 const confirmLoading = ref<boolean>(false)
 const confirm = async () => {
+  console.log('11111', formState)
   try {
     confirmLoading.value = true
     await formRef.value?.validate()
     await systemApi.saveSystemConfig({
-      paramId: undefined,
+      parameterId: formState.parameterId ?? undefined,
       paramCode: formState.paramCode,
       paramName: formState.paramName,
       paramValue: formState.paramValue,