瀏覽代碼

Merge dev into prod (keep prod config files)

liujia 1 月之前
父節點
當前提交
11478ca44a
共有 46 個文件被更改,包括 2553 次插入2272 次删除
  1. 44 0
      CHANGELOG.md
  2. 2 1
      components.d.ts
  3. 2 2
      package.json
  4. 8 8
      pnpm-lock.yaml
  5. 4 0
      src/api/device/types.ts
  6. 17 1
      src/api/stats/index.ts
  7. 31 0
      src/api/stats/types.ts
  8. 3 0
      src/api/user/types.ts
  9. 309 0
      src/components/RadarView/index.vue
  10. 30 0
      src/components/baseChart/index.vue
  11. 117 0
      src/hooks/useDashboardPolling.ts
  12. 26 5
      src/hooks/useDict.ts
  13. 113 0
      src/hooks/useSafeChart.ts
  14. 20 51
      src/layout/index.vue
  15. 9 0
      src/stores/user.ts
  16. 28 0
      src/types/radar.ts
  17. 78 0
      src/utils/chartManager.ts
  18. 0 13
      src/utils/useChartResize.ts
  19. 0 141
      src/utils/useResponsiveLayout.ts
  20. 14 5
      src/views/community/components/add/index.vue
  21. 0 89
      src/views/dashboard/components/AlarmHistoryCard/index.vue
  22. 51 56
      src/views/dashboard/components/AlertFallCompareCard/index.vue
  23. 0 81
      src/views/dashboard/components/DeviceAgeCard/index.vue
  24. 20 25
      src/views/dashboard/components/DeviceLocationCard/index.vue
  25. 48 93
      src/views/dashboard/components/DeviceOnlineRateCard/index.vue
  26. 134 0
      src/views/dashboard/components/DigitRoll/index.vue
  27. 40 53
      src/views/dashboard/components/ElderActivityCard/index.vue
  28. 0 90
      src/views/dashboard/components/FallingHistoryCard/index.vue
  29. 80 0
      src/views/dashboard/components/GuardObjectAgeCard/index.vue
  30. 100 0
      src/views/dashboard/components/GuardObjectTypeCard/index.vue
  31. 127 0
      src/views/dashboard/components/HistoryChartCard/index.vue
  32. 210 0
      src/views/dashboard/components/MonitorPeopleCountCard/index.vue
  33. 0 86
      src/views/dashboard/components/ObjectDistributionCard/index.vue
  34. 0 127
      src/views/dashboard/components/PeopleDetectedCard/index.vue
  35. 52 0
      src/views/dashboard/components/RollingNumber/index.vue
  36. 12 6
      src/views/dashboard/components/TechCard/index.vue
  37. 0 107
      src/views/dashboard/components/dataCard/index.vue
  38. 0 832
      src/views/dashboard/components/screen/index.vue
  39. 0 68
      src/views/dashboard/components/statusItem/index.vue
  40. 638 89
      src/views/dashboard/index.vue
  41. 3 0
      src/views/dashboard/types/index.ts
  42. 43 75
      src/views/device/detail/components/deviceAreaConfig/index.vue
  43. 63 4
      src/views/device/detail/components/deviceBaseConfig/index.vue
  44. 55 162
      src/views/device/detail/index.vue
  45. 3 2
      src/views/device/list/components/addDevice/index.vue
  46. 19 0
      vite.config.ts

+ 44 - 0
CHANGELOG.md

@@ -1,4 +1,48 @@
 
+## v0.7.4 (2025-09-19)
+- feat(组件类型): 添加ASkeleton和ATree组件类型声明 (88424d3)
+
+## v0.7.3 (2025-09-19)
+- feat: 新增雷达检测展示的组件; (a0af0e2)
+
+## v0.7.2 (2025-09-18)
+- feat: 调整设备点位图的位置; (710b1e0)
+
+## v0.7.1 (2025-09-18)
+- fix: 调整家具子区域配置区域设置尺寸; (2d89b5c)
+
+## v0.7.0 (2025-09-18)
+- feat: 智慧大屏管理员支持切换租户;智慧大屏守护天数滚动效果; (56a9e85)
+- feat: 调整返回的路由参数,防止报错; (c6af37c)
+
+## v0.6.10 (2025-09-17)
+- feat: 调整打包体积限制提示,使用tiny-pinyin替换了pinyin-pro,减小打包体积; (30bd39b)
+- fix: 解决打包时提示 MqttClient is not exported 类型问题; (fb8f44e)
+
+## v0.6.9 (2025-09-17)
+- feat: 调整智慧大屏的监护对象名称与组件名称;删除冗余组件; (7e013d1)
+
+## v0.6.8 (2025-09-17)
+- feat: 智慧大屏历史统计数据接口对接与联调;并调整卡片布局展示; (d1063a2)
+- feat: 调整智慧大屏检测对象、安装位置文案的映射; (4f83bac)
+
+## v0.6.7 (2025-09-17)
+- feat: 设备配置新增监护对象年龄与监护对象类型配置; (5152d4d)
+
+## v0.6.6 (2025-09-16)
+- feat: 微调智慧大屏历史统计悬浮展示; (8f980f3)
+
+## v0.6.5 (2025-09-16)
+- feat: 调整添加设备的设备类型值;调整智慧大屏的历史展示组件; (f7a5401)
+- feat: 大屏卡片位置微调; (26fb653)
+
+## v0.6.4 (2025-09-16)
+- feat(dashboard): 重构仪表盘组件和图表管理;联调首页大屏接口,优化图标展示; (a40faf0)
+- feat(dashboard): 重构仪表盘组件并添加数据绑定功能,首页大屏统计接口对接; (9ac8d42)
+
+## v0.6.3 (2025-09-15)
+- feat: 添加设备归属租户允许删除 (8337828)
+
 ## v0.6.2 (2025-09-15)
 - feat: 调整设备固件版本号为hardware; (1d7cd19)
 

+ 2 - 1
components.d.ts

@@ -12,7 +12,6 @@ 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']
@@ -60,6 +59,7 @@ declare module 'vue' {
     AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
     BaseAreaViewer: typeof import('./src/components/baseAreaViewer/index.vue')['default']
     BaseCard: typeof import('./src/components/baseCard/index.vue')['default']
+    BaseChart: typeof import('./src/components/baseChart/index.vue')['default']
     BaseModal: typeof import('./src/components/baseModal/index.vue')['default']
     BasePagination: typeof import('./src/components/basePagination/index.vue')['default']
     BaseWeather: typeof import('./src/components/baseWeather/index.vue')['default']
@@ -68,6 +68,7 @@ declare module 'vue' {
     FurnitureItem: typeof import('./src/components/furnitureItem/index.vue')['default']
     FurnitureList: typeof import('./src/components/furnitureList/index.vue')['default']
     RadarPointCloud: typeof import('./src/components/radarPointCloud/index.vue')['default']
+    RadarView: typeof import('./src/components/RadarView/index.vue')['default']
     RangePicker: typeof import('./src/components/rangePicker/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "ln-web",
-  "version": "0.6.2",
+  "version": "0.7.4",
   "private": true,
   "type": "module",
   "scripts": {
@@ -40,8 +40,8 @@
     "nanoid": "^5.1.5",
     "pinia": "^3.0.3",
     "pinia-plugin-persistedstate": "^4.4.1",
-    "pinyin-pro": "^3.26.0",
     "three": "^0.179.1",
+    "tiny-pinyin": "^1.3.2",
     "vue": "^3.5.17",
     "vue-router": "^4.5.1"
   },

+ 8 - 8
pnpm-lock.yaml

@@ -53,12 +53,12 @@ importers:
       pinia-plugin-persistedstate:
         specifier: ^4.4.1
         version: 4.4.1(pinia@3.0.3(typescript@5.8.3)(vue@3.5.17(typescript@5.8.3)))
-      pinyin-pro:
-        specifier: ^3.26.0
-        version: 3.26.0
       three:
         specifier: ^0.179.1
         version: 0.179.1
+      tiny-pinyin:
+        specifier: ^1.3.2
+        version: 1.3.2
       vue:
         specifier: ^3.5.17
         version: 3.5.17(typescript@5.8.3)
@@ -2356,9 +2356,6 @@ packages:
       typescript:
         optional: true
 
-  pinyin-pro@3.26.0:
-    resolution: {integrity: sha512-HcBZZb0pvm0/JkPhZHWA5Hqp2cWHXrrW/WrV+OtaYYM+kf35ffvZppIUuGmyuQ7gDr1JDJKMkbEE+GN0wfMoGg==}
-
   pkg-types@1.3.1:
     resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
 
@@ -2680,6 +2677,9 @@ packages:
     resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
     engines: {node: '>=12.22'}
 
+  tiny-pinyin@1.3.2:
+    resolution: {integrity: sha512-uHNGu4evFt/8eNLldazeAM1M8JrMc1jshhJJfVRARTN3yT8HEEibofeQ7QETWQ5ISBjd6fKtTVBCC/+mGS6FpA==}
+
   tinyglobby@0.2.14:
     resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
     engines: {node: '>=12.0.0'}
@@ -5348,8 +5348,6 @@ snapshots:
     optionalDependencies:
       typescript: 5.8.3
 
-  pinyin-pro@3.26.0: {}
-
   pkg-types@1.3.1:
     dependencies:
       confbox: 0.1.8
@@ -5728,6 +5726,8 @@ snapshots:
 
   throttle-debounce@5.0.2: {}
 
+  tiny-pinyin@1.3.2: {}
+
   tinyglobby@0.2.14:
     dependencies:
       fdir: 6.4.6(picomatch@4.0.2)

+ 4 - 0
src/api/device/types.ts

@@ -215,6 +215,8 @@ export interface DeviceDetailData {
   updateId: ID // 修改人
   createTime: string // 创建时间
   updateTime: string // 修改时间
+  age: ID // 监护对象年龄
+  guardianshipType: ID // 监护对象类型
 }
 
 /**
@@ -255,4 +257,6 @@ export interface UpdateDeviceParams {
   zzStart?: ID // 雷达监测范围z开始,范围:0-5 cm之内
   zzEnd?: ID // 雷达监测范围z结束,范围:200-300 cm之内
   fallingConfirm?: ID // 设备跌倒确认时间 通常180s
+  age?: number | null // 监护对象年龄
+  guardianshipType?: string | null // 监护对象类型
 }

+ 17 - 1
src/api/stats/index.ts

@@ -17,7 +17,23 @@ export const statsAlarmQuery = (
 
 // 首页大屏统计
 export const statsHomeScreenQuery = (params: {
-  tenantId: number
+  tenantId: string
 }): Promise<ResponseData<TYPE.StatsHomeScreenQueryData>> => {
   return request.post('/stats/screen', params)
 }
+
+// 首页大屏历史跌倒统计
+export const statsHomeScreenFallHistory = (params: {
+  tenantId: string
+  queryType: string // 查询类型 最近七天 day 最近半年 month
+}): Promise<ResponseData<TYPE.StatsHomeScreenFallHistory>> => {
+  return request.post('/stats/falling', params)
+}
+
+// 首页大屏历史告警统计
+export const statsHomeScreenAlarmHistory = (params: {
+  tenantId: string
+  queryType: string // 查询类型 最近七天 day 最近半年 month
+}): Promise<ResponseData<TYPE.StatsHomeScreenAlarmHistory>> => {
+  return request.post('/stats/alarm', params)
+}

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

@@ -113,10 +113,12 @@ type AgeList = {
 type GuardList = {
   guardType: string // 监测对象类型
   count: number // 数量
+  name?: string // 监测对象类型名称
 }
 type InstallPositionList = {
   installPosition: string // 	安装位置
   count: number // 	安装数量
+  name?: string // 安装位置名称
 }
 /**
  * 首页大屏统计出参
@@ -133,3 +135,32 @@ export interface StatsHomeScreenQueryData {
   guardList: GuardList[] // 守护统计信息
   installPositionList: InstallPositionList[] // 安装位置统计信息
 }
+
+/**
+ * 首页大屏历史跌倒统计出参
+ */
+
+export interface StatsHomeScreenFallHistory {
+  monthStatInfo: {
+    month: string
+    fallingCount: number
+  }[]
+  dayStatInfo: {
+    date: string
+    fallingCount: number
+  }[]
+}
+
+/**
+ * 首页大屏历史告警统计出参
+ */
+export interface StatsHomeScreenAlarmHistory {
+  monthStatInfo: {
+    month: string
+    alarmCount: number
+  }[]
+  dayStatInfo: {
+    date: string
+    alarmCount: number
+  }[]
+}

+ 3 - 0
src/api/user/types.ts

@@ -38,4 +38,7 @@ export interface LoginResponseData {
   phone: string // 手机号
   userId: number // userId
   account: string // 账户
+  tenantId: string // 租户ID
+  tenantName: string // 租户名称
+  userType: string // 用户类型  超管 'admin' | 'manager' 其他租户管理员 'user_admin' ...
 }

+ 309 - 0
src/components/RadarView/index.vue

@@ -0,0 +1,309 @@
+<template>
+  <div class="radar-view" :style="areaStyle">
+    <div class="furnitures">
+      <furniture-icon
+        icon="radar"
+        :width="radar.width"
+        :height="radar.length"
+        :style="{
+          left: `${radar.left}px`,
+          top: `${radar.top}px`,
+          position: 'absolute',
+          transform: `translate(-50%, -50%) rotate(${radar.rotate}deg)`,
+          cursor: 'default',
+        }"
+        :draggable="false"
+      />
+
+      <furniture-icon
+        v-for="item in filteredFurniture"
+        :key="item.nanoid"
+        :icon="item.type"
+        :width="item.width"
+        :height="item.length"
+        :style="{
+          left: `${item.left}px`,
+          top: `${item.top}px`,
+          position: 'absolute',
+          rotate: `${item.rotate}deg`,
+          cursor: 'default',
+        }"
+        :draggable="false"
+      />
+    </div>
+
+    <div class="targets">
+      <template v-if="targets && Object.keys(targets).length > 0">
+        <template v-for="t in targets" :key="t.id">
+          <div
+            class="target-dot"
+            :style="{
+              position: 'absolute',
+              width: '18px',
+              height: '18px',
+              background: t.id === 0 ? 'red' : t.id === 1 ? 'blue' : 'green',
+              borderRadius: '50%',
+              transform: `translate3d(${t.displayX}px, ${-t.displayY!}px, 0) translate(-50%, -50%)`,
+              zIndex: 10,
+              transition: 'transform 1s linear',
+              willChange: 'transform',
+            }"
+          >
+            <span
+              style="
+                color: #fff;
+                font-size: 12px;
+                font-weight: 600;
+                position: absolute;
+                left: 50%;
+                top: 50%;
+                transform: translate(-50%, -50%);
+                pointer-events: none;
+              "
+            >
+              {{ t.id + 1 }}
+            </span>
+          </div>
+        </template>
+      </template>
+    </div>
+
+    <div class="content"><slot /></div>
+
+    <div v-if="showInfo" class="info-box">
+      检测区域:{{ areaWidth }} × {{ areaHeight }} cm<br />
+      坐标范围:[{{ xxStart }}, {{ xxEnd }}, {{ yyStart }}, {{ yyEnd }}]<br />
+      正北夹角:{{ angle }}° {{ northArrow }}<br />
+      坐标参考:X轴 {{ xArrow }},Y轴 {{ yArrow }}
+    </div>
+
+    <div v-if="areaWidth > 50 && areaHeight > 50" class="info-toggle" @click="showInfo = !showInfo">
+      <QuestionCircleOutlined />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import type { FurnitureItem, TargetPoint } from '@/types/radar'
+import { QuestionCircleOutlined } from '@ant-design/icons-vue'
+
+defineOptions({ name: 'RadarView' })
+
+interface Props {
+  coordinates: [number, number, number, number] // 坐标边界:[xStart, xEnd, yStart, yEnd]
+  angle: number // 雷达旋转角度(单位:度)
+  furnitureItems?: FurnitureItem[] // 家具列表(包含雷达和其他家具)
+  targets?: TargetPoint[] // 目标点列表(已处理好的点位数据)
+}
+const props = defineProps<Props>()
+
+// 坐标边界拆解为响应式变量
+const xxStart = computed(() => props.coordinates[0])
+const xxEnd = computed(() => props.coordinates[1])
+const yyStart = computed(() => props.coordinates[2])
+const yyEnd = computed(() => props.coordinates[3])
+
+// 区域宽高计算(单位:cm)
+const areaWidth = computed(() => xxEnd.value - xxStart.value)
+const areaHeight = computed(() => yyEnd.value - yyStart.value)
+// 雷达角度(默认值为 0°,表示正北朝上)
+const angle = computed(() => props.angle ?? 0)
+
+/**
+ * 坐标转换函数:将雷达坐标系中的点 (x, y) 转换为 CSS 坐标系中的位置 (left, top)
+ * - 雷达坐标系以左下角为原点,单位为 cm
+ * - CSS 坐标系以左上角为原点,单位为 px
+ * - 支持角度旋转(0°, 90°, 180°, 270°)
+ * 使用场景:用于将家具或目标点定位到页面上
+ */
+function convertRadarToCss(x: number, y: number): { left: number; top: number } {
+  let rx = x,
+    ry = y
+  switch (angle.value) {
+    case 90:
+      ;[rx, ry] = [y, -x]
+      break
+    case 180:
+      ;[rx, ry] = [-x, -y]
+      break
+    case 270:
+      ;[rx, ry] = [-y, x]
+      break
+  }
+  return {
+    left: rx - xxStart.value,
+    top: yyEnd.value - ry,
+  }
+}
+
+// 雷达图标位置计算:固定在坐标原点 (0, 0),并转换为 CSS 坐标
+const radar = computed(() => {
+  const { left, top } = convertRadarToCss(0, 0)
+  return {
+    name: '雷达',
+    type: 'radar',
+    width: 20,
+    length: 20,
+    rotate: 0,
+    left,
+    top,
+    x: 0,
+    y: 0,
+  }
+})
+
+// 过滤家具列表,排除雷达图标,仅保留其他家具
+const filteredFurniture = computed(
+  () => props.furnitureItems?.filter((item) => item.type !== 'radar') ?? []
+)
+
+/**
+ * 雷达区域样式计算:
+ * - 设置区域尺寸
+ * - 添加网格背景(20px 间距)
+ * - 设置边框和阴影
+ * 使用场景:用于渲染雷达区域容器
+ */
+const areaStyle = computed(() => ({
+  width: `${areaWidth.value}px`,
+  height: `${areaHeight.value}px`,
+  position: 'relative' as const,
+  backgroundImage: `
+    linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px),
+    linear-gradient(to right, rgba(0, 0, 0, 0.1) 1px, transparent 1px)
+  `,
+  backgroundSize: '20px 20px',
+  border: '1px solid rgba(0, 0, 0, 0.8)',
+  boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
+  backgroundColor: '#fff',
+}))
+
+// 区域信息展示开关(点击问号按钮切换)
+const showInfo = ref(false)
+
+const northArrow = computed(() => {
+  switch (angle.value) {
+    case 0:
+      return '⬆'
+    case 90:
+      return '➡'
+    case 180:
+      return '⬇'
+    case 270:
+      return '⬅'
+    default:
+      return ''
+  }
+})
+
+const xArrow = computed(() => {
+  switch (angle.value) {
+    case 0:
+      return '➡'
+    case 90:
+      return '⬆'
+    case 180:
+      return '⬅'
+    case 270:
+      return '⬇'
+    default:
+      return ''
+  }
+})
+
+const yArrow = computed(() => {
+  switch (angle.value) {
+    case 0:
+      return '⬆'
+    case 90:
+      return '⬅'
+    case 180:
+      return '⬇'
+    case 270:
+      return '➡'
+    default:
+      return ''
+  }
+})
+</script>
+
+<style lang="less" scoped>
+.radar-view {
+  flex-shrink: 0;
+
+  .furnitures {
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    position: absolute;
+    top: 0;
+    z-index: 1;
+  }
+
+  .targets {
+    width: 100%;
+    height: 100%;
+    position: absolute;
+    top: 0;
+    z-index: 2;
+  }
+
+  .content {
+    width: 100%;
+    height: 100%;
+    position: absolute;
+    top: 0;
+    z-index: 3;
+  }
+
+  .info-toggle {
+    position: absolute;
+    right: 4px;
+    bottom: 4px;
+    z-index: 100;
+    border: 1px solid rgba(0, 0, 0, 0.2);
+    padding: 4px 6px;
+    font-size: 12px;
+    border-radius: 4px;
+    cursor: pointer;
+    color: #333;
+    transition: background 0.2s;
+    font-size: 14px;
+
+    &:hover {
+      background: rgba(0, 0, 0, 0.1);
+      border: 1px solid rgba(0, 0, 0, 0.2);
+    }
+  }
+
+  .info-box {
+    position: absolute;
+    right: 4px;
+    bottom: 36px;
+    font-size: 12px;
+    color: #333;
+    background: rgba(255, 255, 255, 0.85);
+    padding: 6px 10px;
+    border-radius: 6px;
+    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
+    z-index: 99;
+    line-height: 1.5;
+    pointer-events: none;
+    min-width: 200px;
+  }
+}
+
+.target-dot {
+  span {
+    color: #fff;
+    font-size: 12px;
+    font-weight: 600;
+    position: absolute;
+    left: 50%;
+    top: 50%;
+    transform: translate(-50%, -50%);
+    pointer-events: none;
+  }
+}
+</style>

+ 30 - 0
src/components/baseChart/index.vue

@@ -0,0 +1,30 @@
+<template>
+  <div ref="chartRef" class="base-chart" :style="{ height: height ? height + 'px' : '100%' }" />
+</template>
+
+<script setup lang="ts">
+import { ref, toRef } from 'vue'
+import * as echarts from 'echarts'
+import { useSafeChart } from '@/hooks/useSafeChart'
+
+defineOptions({ name: 'BaseChart' })
+
+const props = defineProps<{
+  option: echarts.EChartsOption
+  height?: number
+}>()
+
+const chartRef = ref<HTMLDivElement | null>(null)
+const optionRef = toRef(props, 'option')
+
+useSafeChart(chartRef, optionRef)
+</script>
+
+<style scoped lang="less">
+.base-chart {
+  width: 100%;
+  height: 100%;
+  min-height: 150px;
+  min-width: 100px;
+}
+</style>

+ 117 - 0
src/hooks/useDashboardPolling.ts

@@ -0,0 +1,117 @@
+import { ref } from 'vue'
+import * as statsApi from '@/api/stats'
+import type {
+  StatsHomeScreenQueryData,
+  StatsHomeScreenFallHistory,
+  StatsHomeScreenAlarmHistory,
+} from '@/api/stats/types'
+
+export function useDashboardPolling(options: { tenantId: string }) {
+  const todayScreenData = ref<StatsHomeScreenQueryData | null>(null)
+  const fallHistoryData = ref<StatsHomeScreenFallHistory | null>(null)
+  const alarmHistoryData = ref<StatsHomeScreenAlarmHistory | null>(null)
+
+  const fallQueryType = ref<'day' | 'month'>('day')
+  const alarmQueryType = ref<'day' | 'month'>('day')
+
+  let realtimeTimer: ReturnType<typeof setInterval> | null = null
+  let businessTimer: ReturnType<typeof setInterval> | null = null
+  let isVisible = true
+
+  const MAX_RETRY = 3
+  let retryCountRealtime = 0
+  let retryCountBusiness = 0
+
+  const getTodayData = async () => {
+    if (!isVisible) return
+    try {
+      const res = await statsApi.statsHomeScreenQuery({
+        tenantId: options.tenantId,
+      })
+      todayScreenData.value = res.data
+      retryCountRealtime = 0
+    } catch {
+      retryCountRealtime++
+      if (retryCountRealtime < MAX_RETRY) {
+        setTimeout(getTodayData, 1000)
+      }
+    }
+  }
+
+  const getHistoryData = async () => {
+    if (!isVisible) return
+    try {
+      const [fallRes, alarmRes] = await Promise.all([
+        statsApi.statsHomeScreenFallHistory({
+          tenantId: options.tenantId,
+          queryType: fallQueryType.value,
+        }),
+        statsApi.statsHomeScreenAlarmHistory({
+          tenantId: options.tenantId,
+          queryType: alarmQueryType.value,
+        }),
+      ])
+      fallHistoryData.value = fallRes.data
+      alarmHistoryData.value = alarmRes.data
+      retryCountBusiness = 0
+    } catch {
+      retryCountBusiness++
+      if (retryCountBusiness < MAX_RETRY) {
+        setTimeout(getHistoryData, 2000)
+      }
+    }
+  }
+
+  const start = () => {
+    getTodayData()
+    getHistoryData()
+    realtimeTimer = setInterval(getTodayData, 5000)
+    businessTimer = setInterval(getHistoryData, 30000)
+    document.addEventListener('visibilitychange', handleVisibilityChange)
+  }
+
+  const stop = () => {
+    if (realtimeTimer) clearInterval(realtimeTimer)
+    if (businessTimer) clearInterval(businessTimer)
+    realtimeTimer = null
+    businessTimer = null
+    document.removeEventListener('visibilitychange', handleVisibilityChange)
+  }
+
+  const handleVisibilityChange = () => {
+    isVisible = document.visibilityState === 'visible'
+    if (isVisible) {
+      start()
+    } else {
+      stop()
+    }
+  }
+
+  const updateFallQueryType = async (type: 'day' | 'month') => {
+    fallQueryType.value = type
+    const res = await statsApi.statsHomeScreenFallHistory({
+      tenantId: options.tenantId,
+      queryType: fallQueryType.value,
+    })
+    fallHistoryData.value = res.data
+  }
+
+  const updateAlarmQueryType = async (type: 'day' | 'month') => {
+    alarmQueryType.value = type
+    const res = await statsApi.statsHomeScreenAlarmHistory({
+      tenantId: options.tenantId,
+      queryType: alarmQueryType.value,
+    })
+    alarmHistoryData.value = res.data
+  }
+
+  return {
+    todayScreenData,
+    fallHistoryData,
+    alarmHistoryData,
+    updateFallQueryType,
+    updateAlarmQueryType,
+    start,
+    stop,
+  }
+}

+ 26 - 5
src/hooks/useDict.ts

@@ -7,14 +7,30 @@ export interface DictItem {
 }
 
 /**
- * 获取字典值列表
- * @param dicType 字典类型
- * @example 使用示例
- * const { dictList, fetchDict } = useDict('DIC_TYPE')
- * @enum { string } institution_type 机构类型
+ * 获取指定类型的字典数据,并返回列表和映射表
+ *
+ * @param dicType 字典类型标识,例如 'institution_type'、'guardianship_type' 等
+ * @example 返回值结构:
+ *  {
+ *   dictList: Ref<DictItem[]> 字典项列表,包含 label 和 value
+ *   dictNameMap: Ref<Record<string, string>> 字典项映射表,value → label
+ *   fetchDict: () => Promise<void> 异步方法,用于拉取字典数据
+ * }
+ *
+ * @example 使用示例:
+ * const { dictList, dictNameMap, fetchDict } = useDict('institution_type')
+ * await fetchDict()
+ * const name = dictNameMap.value['A01'] || '未知'
+ *
+ * @enum {string}
+ * institution_type 机构类型
+ * * guardianship_type 监护类型
+ * * device_type 设备类型
+ * * install_position 安装位置
  */
 export const useDict = (dicType: string) => {
   const dictList = ref<DictItem[]>([])
+  const dictNameMap = ref<Record<string, string>>({})
 
   const fetchDict = async () => {
     console.log('👏👏👏useDict fetchDict', dicType)
@@ -27,14 +43,19 @@ export const useDict = (dicType: string) => {
             value: item.itemCode,
           }))
         : []
+
+      // 构建 value → label 映射表
+      dictNameMap.value = Object.fromEntries(dictList.value.map((item) => [item.value, item.label]))
     } catch (error) {
       console.error('获取字典数据失败:', error)
       dictList.value = []
+      dictNameMap.value = {}
     }
   }
 
   return {
     dictList,
+    dictNameMap,
     fetchDict,
   }
 }

+ 113 - 0
src/hooks/useSafeChart.ts

@@ -0,0 +1,113 @@
+import { onMounted, onUnmounted, watch, ref, nextTick, type Ref } from 'vue'
+import * as echarts from 'echarts'
+
+export function useSafeChart(
+  chartRef: Ref<HTMLDivElement | null>,
+  option: Ref<echarts.EChartsOption>
+) {
+  const chartInstance = ref<echarts.ECharts | null>(null)
+  let isAlive = true
+
+  const shouldInjectGraphic = (opt: echarts.EChartsOption): boolean => {
+    const series = opt?.series
+    if (!Array.isArray(series) || series.length === 0) return true
+
+    const firstSeries = series[0]
+    const data = firstSeries?.data
+
+    return !Array.isArray(data) || data.length === 0
+  }
+
+  const getEmptyGraphic = (): echarts.EChartsOption['graphic'] => [
+    {
+      type: 'text',
+      left: 'center',
+      top: 'middle',
+      style: {
+        text: '暂无数据',
+        fontSize: 16,
+        fill: '#9cc5e0',
+      },
+    },
+  ]
+
+  const applyOption = (opt: echarts.EChartsOption) => {
+    if (!chartInstance.value) return
+
+    const enhancedOption = {
+      ...opt,
+      graphic: shouldInjectGraphic(opt)
+        ? getEmptyGraphic()
+        : Array.isArray(opt.graphic)
+          ? opt.graphic
+          : [],
+    }
+
+    chartInstance.value.setOption(enhancedOption, true)
+  }
+
+  const initChart = () => {
+    const dom = chartRef.value
+    if (!dom || dom.offsetWidth === 0) return
+
+    const existing = echarts.getInstanceByDom(dom)
+    if (existing) echarts.dispose(dom)
+
+    chartInstance.value = echarts.init(dom)
+    applyOption(option.value)
+  }
+
+  const resizeChart = () => {
+    const chart = chartInstance.value
+    const dom = chart?.getDom()
+
+    if (!isAlive || !chart || !dom || dom.offsetWidth === 0) return
+
+    try {
+      chart.resize()
+    } catch (error) {
+      console.warn('Chart resize failed, attempting recovery:', error)
+
+      try {
+        echarts.dispose(dom)
+        const newChart = echarts.init(dom)
+        chartInstance.value = newChart
+        applyOption(option.value)
+      } catch (retryError) {
+        console.error('Chart recovery failed:', retryError)
+      }
+    }
+  }
+
+  onMounted(() => {
+    nextTick(() => {
+      initChart()
+      window.addEventListener('resize', resizeChart)
+    })
+  })
+
+  onUnmounted(() => {
+    isAlive = false
+    window.removeEventListener('resize', resizeChart)
+    if (chartInstance.value) {
+      try {
+        chartInstance.value.dispose()
+      } catch (error) {
+        console.warn('Chart dispose failed:', error)
+      }
+      chartInstance.value = null
+    }
+  })
+
+  watch(
+    option,
+    (newOption) => {
+      applyOption(newOption)
+    },
+    { deep: true }
+  )
+
+  return {
+    chartInstance,
+  }
+}

+ 20 - 51
src/layout/index.vue

@@ -13,14 +13,6 @@
         </div>
         <div class="text">雷能信息后台管理</div>
       </div>
-      <!-- <a-menu
-        v-model:selectedKeys="state.selectedKeys"
-        mode="inline"
-        :theme="theme"
-        :inline-collapsed="state.collapsed"
-        :items="menus"
-        @click="clickMenuItemHandler"
-      ></a-menu> -->
       <SideMenu :collapsed="collapsed" />
     </a-layout-sider>
 
@@ -62,7 +54,6 @@
         <slot name="footer">
           <span>合肥雷能信息技术有限公司 © 2025 All Rights Reserved.</span>
           <span>&nbsp;&nbsp;&nbsp;&nbsp;版本号:{{ version }}</span>
-          <!-- <span>&nbsp;&nbsp;&nbsp;&nbsp;构建时间:{{ buildTime }}</span> -->
         </slot>
       </a-layout-footer>
     </a-layout>
@@ -74,11 +65,10 @@
 <script setup lang="ts">
 import { ref, reactive, watchEffect, computed, h, onUnmounted, onMounted } from 'vue'
 import userDropdown from './components/userDropdown/index.vue'
-// import { menus } from '@/const/menus'
 import { useRoute, useRouter } from 'vue-router'
 import timeNow from './components/timeNow/index.vue'
 import type { Route } from 'ant-design-vue/es/breadcrumb/Breadcrumb'
-import mqtt, { MqttClient } from 'mqtt'
+import mqtt, { type MqttClient } from 'mqtt'
 import { useUserStore } from '@/stores/user'
 import AlertModal from './components/alertModal/index.vue'
 import { ArrowLeftOutlined, SyncOutlined } from '@ant-design/icons-vue'
@@ -87,7 +77,6 @@ import SideMenu from './components/sideMenu/index.vue'
 const userStore = useUserStore()
 const userId = ref(userStore?.userInfo?.userId || '')
 const version = __APP_VERSION__
-// const buildTime = __BUILD_TIME__
 
 const emit = defineEmits(['refresh'])
 // 刷新
@@ -103,23 +92,11 @@ const onBreakpoint = (broken: boolean) => {
   }
 }
 
-// 需要缓存的组件列表
-// const keepAliveComponents = computed(() => {
-//   return route.matched.filter((item) => item.meta.keepAlive).map((item) => item.name as string)
-// })
-
 let mqttClient: MqttClient | null = null
-// let mqttTimeout: number | null = null
-// const MQTT_TIMEOUT_MS = 10000 // 10秒
 
 const resetMqttTimeout = () => {
   console.log('🚀关闭MQTT连接')
   closeMqtt()
-  // if (mqttTimeout) clearTimeout(mqttTimeout)
-  // mqttTimeout = window.setTimeout(() => {
-  //   console.log('MQTT超时未收到新消息')
-  //   closeMqtt()
-  // }, MQTT_TIMEOUT_MS)
 }
 
 const MqttData = ref()
@@ -311,45 +288,33 @@ const itemRender = (options: { route: Route; params: any; routes: Route[]; paths
 
 const clickedMenuItem = ref('')
 watchEffect(() => {
-  // if (routes.value.length > 0 && routes.value[0].path) {
-  //   state.selectedKeys = [routes.value[0].path.split('/')[1] || 'home']
-  // }
   state.selectedKeys = [clickedMenuItem.value]
 })
 
-// const clickMenuItemHandler = ({ key, keyPath }: { key: string; keyPath: string[] }) => {
-//   console.log('🚀clickMenuItemHandler🚀', key, keyPath)
-//   clickedMenuItem.value = key
-//   if (route.name !== key) {
-//     router.push({ name: key })
-//   }
-// }
-
 const canGoBack = ref(false)
 const navigationHistory = ref<string[]>([])
 let isBackNavigation = false
 
 onMounted(() => {
-  // 初始化导航历史
-  navigationHistory.value = [router.currentRoute.value.path]
+  // 初始化记录当前完整路径(包含参数)
+  navigationHistory.value = [router.currentRoute.value.fullPath]
   canGoBack.value = false
 })
 
-// 监听路由变化,更新导航历史和canGoBack状态
 watchEffect(() => {
-  const currentPath = router.currentRoute.value.path
+  const currentFullPath = router.currentRoute.value.fullPath
 
   if (isBackNavigation) {
-    // 如果是返回导航,不添加到历史记录
+    // 返回导航时不记录
     isBackNavigation = false
   } else {
-    // 确保只添加新的路径
-    if (navigationHistory.value[navigationHistory.value.length - 1] !== currentPath) {
-      navigationHistory.value.push(currentPath)
+    // 只记录新的路径(避免重复)
+    if (navigationHistory.value[navigationHistory.value.length - 1] !== currentFullPath) {
+      navigationHistory.value.push(currentFullPath)
     }
   }
 
-  // 当历史记录长度大于1时可以返回
+  // 是否可以返回
   canGoBack.value = navigationHistory.value.length > 1
 })
 
@@ -358,12 +323,12 @@ const backHandler = async () => {
     // 移除当前路径
     navigationHistory.value.pop()
 
-    // 设置返回导航标志
+    // 设置返回标志
     isBackNavigation = true
 
-    // 返回到上一个路径
-    const prevPath = navigationHistory.value[navigationHistory.value.length - 1]
-    await router.push(prevPath)
+    // 跳转到上一个完整路径(包含参数)
+    const prevFullPath = navigationHistory.value[navigationHistory.value.length - 1]
+    await router.push(prevFullPath)
   }
 }
 
@@ -454,9 +419,13 @@ const openSmartScreen = () => {
     .smartScreen {
       cursor: pointer;
       color: #eee;
-      &:hover {
-        color: #85b8ff;
-      }
+      font-size: 1.2em;
+      font-weight: bold;
+      background: linear-gradient(to right, #ff6ec4, #f9d423, #00c9ff, #92fe9d);
+      -webkit-background-clip: text;
+      background-clip: text;
+      -webkit-text-fill-color: transparent;
+      text-shadow: 0 0 2px rgba(255, 255, 255, 0.3);
     }
 
     .refresh {

+ 9 - 0
src/stores/user.ts

@@ -22,6 +22,9 @@ type UserInfo = {
   phone: string // 手机号
   userId: number // userId
   account: string // 账户
+  tenantId: string // 租户ID
+  tenantName: string // 租户名称
+  userType: string // 用户类型  超管 'admin' | 'manager' 其他租户管理员 'user_admin' ...
 }
 
 export const useUserStore = defineStore(
@@ -44,6 +47,9 @@ export const useUserStore = defineStore(
       phone: '',
       userId: 0,
       account: '',
+      tenantId: '',
+      tenantName: '',
+      userType: '',
     })
 
     // 登录
@@ -94,6 +100,9 @@ export const useUserStore = defineStore(
         phone: '',
         userId: 0,
         account: '',
+        tenantId: '',
+        tenantName: '',
+        userType: '',
       }
       const redirectPath = router.currentRoute.value.fullPath || ''
       console.log('✅ userStore Logout', redirectPath)

+ 28 - 0
src/types/radar.ts

@@ -0,0 +1,28 @@
+/**
+ * 家具元素,用于在雷达区域中展示和编辑
+ */
+export interface FurnitureItem {
+  name: string // 家具名称(如:床、桌子)
+  type: string // 家具类型标识(如:'bed'、'table')
+  width: number // 家具宽度(单位:px)
+  length: number // 家具长度(单位:px)
+  left: number // CSS 坐标系下的 left 值(用于定位)
+  top: number // CSS 坐标系下的 top 值(用于定位)
+  rotate: number // 家具旋转角度(单位:deg)
+  x?: number // 雷达坐标系下的 X 坐标(用于接口报错)
+  y?: number // 雷达坐标系下的 Y 坐标(用于接口报错)
+  nanoid?: string // 可选:用于标识家具的唯一 ID(如 nanoid)
+}
+
+/**
+ * 雷达点位图元素,用于展示检测目标位置
+ */
+export interface TargetPoint {
+  id: number // 点位唯一标识(通常为目标编号)
+  x: number // 雷达坐标系下的 X 坐标(以原点为参考)
+  y: number // 雷达坐标系下的 Y 坐标(以原点为参考)
+  displayX?: number // 可选:CSS 坐标系下的 X 坐标(如果已转换)
+  displayY?: number // 可选:CSS 坐标系下的 Y 坐标(如果已转换)
+  color?: string // 可选:点位颜色(如 'red'、'blue'、'green')
+  label?: string // 可选:点位标签(如 '1号目标')
+}

+ 78 - 0
src/utils/chartManager.ts

@@ -0,0 +1,78 @@
+import { onMounted, onUnmounted, ref } from 'vue'
+import * as echarts from 'echarts'
+
+const chartInstances: Map<string, echarts.ECharts> = new Map()
+let instanceCounter = 0
+
+export 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 safeInitChart(dom: HTMLElement): echarts.ECharts {
+  const existing = echarts.getInstanceByDom(dom)
+  if (existing) {
+    echarts.dispose(dom)
+  }
+  return echarts.init(dom)
+}
+
+export function useResponsiveLayout() {
+  const handleResize = () => {
+    windowSize.value = {
+      width: window.innerWidth,
+      height: window.innerHeight,
+    }
+
+    requestAnimationFrame(() => {
+      chartInstances.forEach((chart, id) => {
+        try {
+          if (chart.getDom()) {
+            chart.resize()
+          } else {
+            console.warn(`Chart ${id} 已被销毁或 DOM 不存在,跳过 resize`)
+          }
+        } catch (error) {
+          console.warn(`Chart resize failed for ${id}:`, error)
+        }
+      })
+    })
+  }
+
+  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 }
+}

+ 0 - 13
src/utils/useChartResize.ts

@@ -1,13 +0,0 @@
-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()
-  })
-}

+ 0 - 141
src/utils/useResponsiveLayout.ts

@@ -1,141 +0,0 @@
-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)
-      }
-    })
-  })
-}

+ 14 - 5
src/views/community/components/add/index.vue

@@ -144,7 +144,7 @@ import { message, type FormInstance } from 'ant-design-vue'
 import * as tenantApi from '@/api/tenant'
 import pcdCascader from '../pcdCascader/index.vue'
 import { InfoCircleOutlined } from '@ant-design/icons-vue'
-import { pinyin } from 'pinyin-pro'
+import TinyPinyin from 'tiny-pinyin'
 import * as systemApi from '@/api/system'
 import { useDict } from '@/hooks/useDict'
 
@@ -320,14 +320,23 @@ const cancel = () => {
 // 添加小区
 const addCommunity = async () => {
   try {
-    const tenantCodes = pinyin(formState.tenantName, { toneType: 'none', type: 'array' })
-      .map((word) => word.charAt(0))
-      .join('')
+    const getTenantCode = (name: string): string => {
+      if (!TinyPinyin.isSupported()) return ''
+      return name
+        .split('')
+        .map((char) => {
+          const py = TinyPinyin.convertToPinyin(char)
+          return py.charAt(0)
+        })
+        .join('')
+    }
+
+    const tenantCodes = getTenantCode(formState.tenantName)
     const res = await tenantApi.queryTenantAdd({
       tenantId: formState.tenantId || '',
       tenantCode: tenantCodes,
       tenantName: formState.tenantName,
-      tenantType: formState.tenantType! || '', // 'residential_property'
+      tenantType: formState.tenantType! || '',
       province: formState.pcdStrs[0] || '',
       city: formState.pcdStrs[1] || '',
       district: formState.pcdStrs[2] || '',

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

@@ -1,89 +0,0 @@
-<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>

+ 51 - 56
src/views/dashboard/components/AlertFallCompareCard/index.vue

@@ -1,69 +1,69 @@
 <template>
   <TechCard>
     <div class="card-header">
-      <div class="title">跌倒与告警统计</div>
+      <div class="title">今日跌倒与告警</div>
     </div>
-    <div ref="chartRef" class="chart-container" />
+    <BaseChart :option="chartOption" :height="180" />
   </TechCard>
 </template>
 
 <script lang="ts" setup>
-import { ref, onMounted } from 'vue'
-import * as echarts from 'echarts'
+import { computed } from 'vue'
 import TechCard from '../TechCard/index.vue'
 
 defineOptions({ name: 'AlertFallCompareCard' })
 
-const chartRef = ref<HTMLDivElement | null>(null)
-const fallCount = 3
-const alertCount = 1
+interface Props {
+  fallCount: number
+  alertCount: number
+}
 
-onMounted(() => {
-  if (!chartRef.value) return
-  const chart = echarts.init(chartRef.value)
+const props = withDefaults(defineProps<Props>(), {
+  fallCount: 0,
+  alertCount: 0,
+})
 
-  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,
-        },
+const chartOption = computed(() => ({
+  tooltip: {
+    trigger: 'item',
+    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: [props.fallCount, props.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>
@@ -77,9 +77,4 @@ onMounted(() => {
     color: #00f0ff;
   }
 }
-
-.chart-container {
-  width: 100%;
-  height: 200px;
-}
 </style>

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

@@ -1,81 +0,0 @@
-<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>

+ 20 - 25
src/views/dashboard/components/DeviceLocationCard/index.vue

@@ -1,29 +1,31 @@
 <template>
   <TechCard>
     <div class="card-title">设备安装位置分布</div>
-    <div ref="chartRef" class="chart-container" />
+    <BaseChart :option="chartOption" :height="200" />
   </TechCard>
 </template>
 
 <script setup lang="ts">
-import { onMounted, ref } from 'vue'
-import * as echarts from 'echarts'
-import { useChartResize } from '@/utils/useChartResize'
+import { computed } from 'vue'
 import TechCard from '../TechCard/index.vue'
 
-defineOptions({
-  name: 'DeviceLocationCard',
-})
+defineOptions({ name: 'DeviceLocationCard' })
+
+interface Props {
+  data: Array<{ name?: string; count: number; installPosition: string }>
+}
 
-const chartRef = ref<HTMLDivElement | null>(null)
+const props = withDefaults(defineProps<Props>(), {
+  data: () => [],
+})
 
-onMounted(() => {
-  if (!chartRef.value) return
-  const chart = echarts.init(chartRef.value)
+const chartOption = computed(() => {
+  const locationNames = props.data.map((item) => item.name || item.installPosition)
+  const locationValues = props.data.map((item) => item.count)
 
-  chart.setOption({
+  return {
     tooltip: {
-      trigger: 'axis',
+      trigger: 'item',
       axisPointer: { type: 'shadow' },
       formatter: '{b}: {c} 台',
     },
@@ -40,7 +42,7 @@ onMounted(() => {
     },
     yAxis: {
       type: 'category',
-      data: ['卫生间', '卧室', '客厅', '餐厅'],
+      data: locationNames,
       axisLabel: { color: '#9cc5e0' },
       axisTick: { show: false },
       axisLine: { show: false },
@@ -48,12 +50,12 @@ onMounted(() => {
     series: [
       {
         type: 'bar',
-        data: [12, 8, 6, 4],
+        data: locationValues,
         barWidth: 14,
         itemStyle: {
           color: (params: { dataIndex: number }) => {
             const colors = ['#4dc9e6', '#6de4ff', '#2572ed', '#1a57c9']
-            return colors[params.dataIndex]
+            return colors[params.dataIndex % colors.length]
           },
         },
         label: {
@@ -64,13 +66,11 @@ onMounted(() => {
         },
       },
     ],
-  })
-
-  useChartResize(chart, chartRef.value)
+  }
 })
 </script>
 
-<style scoped>
+<style scoped lang="less">
 .card-title {
   font-size: 16px;
   font-weight: bold;
@@ -78,9 +78,4 @@ onMounted(() => {
   margin-bottom: 12px;
   text-align: center;
 }
-.chart-container {
-  width: 100%;
-  height: 100%;
-  min-height: 150px;
-}
 </style>

+ 48 - 93
src/views/dashboard/components/DeviceOnlineRateCard/index.vue

@@ -3,108 +3,72 @@
     <div class="card-header">
       <div class="title">设备在线率</div>
     </div>
-    <div ref="chartRef" class="chart-container"></div>
+    <BaseChart :option="chartOption" />
     <div class="footer">
       <div class="label">设备数量</div>
-      <div class="count">{{ onlineCount }} /1258 台</div>
+      <div class="count">{{ onlineCount }} / {{ deviceCount }} 台</div>
     </div>
   </TechCard>
 </template>
 
 <script lang="ts" setup>
-import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
-import * as echarts from 'echarts'
+import { computed } from 'vue'
 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)
+defineOptions({ name: 'DeviceOnlineRateCard' })
 
-const createChart = () => {
-  if (!chartRef.value) return
-  const chart = echarts.init(chartRef.value)
-  chartInstance.value = chart
+type Props = {
+  onlineCount: number
+  deviceCount: number
+}
+const props = withDefaults(defineProps<Props>(), {
+  onlineCount: 0,
+  deviceCount: 0,
+})
 
-  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',
-        },
+const onlineRate = computed(() =>
+  props.deviceCount === 0
+    ? '0'
+    : Math.round((props.onlineCount / props.deviceCount) * 100).toFixed(2)
+)
+
+const chartOption = computed(() => ({
+  series: [
+    {
+      type: 'liquidFill',
+      radius: '70%',
+      center: ['50%', '50%'],
+      data: [parseFloat(onlineRate.value) / 100],
+      label: {
+        formatter: `${onlineRate.value}%`,
+        fontSize: '1em',
+        fontWeight: 'bold',
+        color: '#00f0ff',
+      },
+      outline: {
+        show: true,
+        borderDistance: 4,
         itemStyle: {
-          color: '#00f0ff',
-          opacity: 0.6,
+          borderColor: '#00f0ff',
+          borderWidth: 4,
         },
       },
-    ],
-  })
-}
-
-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
-  }
-})
+      backgroundStyle: {
+        color: '#2c5364',
+      },
+      itemStyle: {
+        color: '#00f0ff',
+        opacity: 0.6,
+      },
+    },
+  ],
+}))
 </script>
 
 <style lang="less" scoped>
 .card-header {
   text-align: center;
-  margin-bottom: 12px;
 
   .title {
     font-size: 16px;
@@ -113,25 +77,16 @@ onUnmounted(() => {
   }
 }
 
-.chart-container {
-  width: 100%;
-  height: 160px;
-  min-height: 120px;
-  position: relative;
-}
-
 .footer {
   display: flex;
   justify-content: center;
   align-items: center;
+  flex-wrap: wrap;
   margin-top: 12px;
+  gap: 8px;
   font-size: 14px;
   color: #9cc5e0;
 
-  .label {
-    margin-right: 8px;
-  }
-
   .count {
     font-weight: bold;
     color: #00f0ff;

+ 134 - 0
src/views/dashboard/components/DigitRoll/index.vue

@@ -0,0 +1,134 @@
+<template>
+  <div class="digit-container" :style="{ height: itemHeight + 'px', width: itemWidth + 'px' }">
+    <div class="digit-list" ref="listRef" :style="listStyle" @transitionend="onTransitionEnd">
+      <div
+        v-for="(n, i) in numbers"
+        :key="i"
+        class="digit"
+        :style="{ height: itemHeight + 'px', lineHeight: itemHeight + 'px' }"
+      >
+        {{ n }}
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch, onMounted, nextTick } from 'vue'
+
+defineOptions({
+  name: 'DigitRoll',
+})
+
+const props = defineProps<{
+  digit: number
+  duration?: number
+  delay?: number
+  height?: number
+  width?: number
+  cycles?: number
+  initialRoll?: boolean
+}>()
+
+const duration = props.duration ?? 800
+const delay = props.delay ?? 0
+const itemHeight = props.height ?? 60
+const itemWidth = props.width ?? 40
+const cycles = Math.max(6, props.cycles ?? 20)
+
+const numbers = computed(() => {
+  const arr: number[] = []
+  for (let c = 0; c < cycles; c++) {
+    for (let d = 0; d < 10; d++) arr.push(d)
+  }
+  return arr
+})
+
+const middleCycleIndex = Math.floor(cycles / 2) * 10
+const currentIndex = ref(middleCycleIndex + (props.digit % 10))
+const transitioning = ref(false)
+const listRef = ref<HTMLElement | null>(null)
+let pendingTimer: number | null = null
+
+const listStyle = computed(() => ({
+  transform: `translateY(-${currentIndex.value * itemHeight}px)`,
+  transitionProperty: 'transform',
+  transitionDuration: transitioning.value ? `${duration}ms` : '0ms',
+  transitionTimingFunction: 'cubic-bezier(0.2, 0.9, 0.2, 1)',
+}))
+
+function normalizeIndexAfterTransition() {
+  const digit = currentIndex.value % 10
+  transitioning.value = false
+  nextTick(() => {
+    currentIndex.value = middleCycleIndex + digit
+  })
+}
+
+function onTransitionEnd(e?: TransitionEvent) {
+  if (!e || e.propertyName === 'transform') {
+    normalizeIndexAfterTransition()
+  }
+}
+
+function rollTo(target: number, startDelay = 0) {
+  if (pendingTimer) {
+    clearTimeout(pendingTimer)
+    pendingTimer = null
+  }
+
+  const currentDigit = currentIndex.value % 10
+  const offset = ((target - currentDigit + 10) % 10) + 10
+  const targetIndex = currentIndex.value + offset
+
+  pendingTimer = window.setTimeout(() => {
+    transitioning.value = true
+    currentIndex.value = targetIndex
+    pendingTimer = null
+  }, startDelay)
+}
+
+watch(
+  () => props.digit,
+  (newVal, oldVal) => {
+    if (newVal == null) return
+    if (newVal !== oldVal) rollTo(newVal, delay)
+  },
+  { immediate: false }
+)
+
+onMounted(() => {
+  currentIndex.value = middleCycleIndex
+  if (props.initialRoll) {
+    rollTo(props.digit, delay)
+  } else {
+    currentIndex.value = middleCycleIndex + (props.digit % 10)
+  }
+})
+</script>
+
+<style scoped lang="less">
+.digit-container {
+  overflow: hidden;
+  display: inline-block;
+  vertical-align: middle;
+  border-radius: 6px;
+  background: #0b173f;
+  border: 1px solid #2a3b5a;
+  box-shadow: 0 0 8px rgba(77, 201, 230, 0.15);
+}
+
+.digit-list {
+  display: flex;
+  flex-direction: column;
+  will-change: transform;
+}
+
+.digit {
+  text-align: center;
+  font-size: 36px;
+  font-weight: 700;
+  color: #e0e0e0;
+  user-select: none;
+}
+</style>

+ 40 - 53
src/views/dashboard/components/ElderActivityCard/index.vue

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

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

@@ -1,90 +0,0 @@
-<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>

+ 80 - 0
src/views/dashboard/components/GuardObjectAgeCard/index.vue

@@ -0,0 +1,80 @@
+<template>
+  <TechCard>
+    <div class="card-title">监护对象年龄分布</div>
+    <BaseChart :option="chartOption" :height="220" />
+  </TechCard>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import TechCard from '../TechCard/index.vue'
+
+defineOptions({ name: 'GuardObjectAgeCard' })
+
+interface AgeItem {
+  ageRange: string
+  count: number
+}
+
+interface Props {
+  ageList: AgeItem[]
+}
+
+const props = defineProps<Props>()
+
+const chartOption = computed(() => {
+  if (!props.ageList || props.ageList.length === 0) {
+    return {
+      series: [],
+    }
+  }
+
+  const data = props.ageList.map((item) => ({
+    name: item.ageRange || '未知',
+    value: item.count ?? 0,
+  }))
+
+  return {
+    tooltip: {
+      trigger: 'item',
+      appendToBody: true,
+      formatter: '{b}: {c}',
+    },
+    legend: {
+      orient: 'horizontal',
+      bottom: 10,
+      textStyle: {
+        color: '#fff',
+        fontSize: 12,
+      },
+    },
+    series: [
+      {
+        name: '设备年龄层次',
+        type: 'pie',
+        radius: ['20%', '55%'],
+        center: ['50%', '40%'],
+        roseType: 'area',
+        label: { show: false },
+        labelLine: { show: false },
+        data,
+        itemStyle: {
+          color: (params: { dataIndex: number }) => {
+            const colors = ['#4dc9e6', '#2572ed', '#6de4ff', '#1a57c9', '#00bcd4']
+            return colors[params.dataIndex % colors.length]
+          },
+        },
+      },
+    ],
+  }
+})
+</script>
+
+<style scoped lang="less">
+.card-title {
+  font-size: 16px;
+  font-weight: bold;
+  color: #00f0ff;
+  text-align: center;
+}
+</style>

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

@@ -0,0 +1,100 @@
+<template>
+  <TechCard>
+    <div class="card-title">监护对象类型</div>
+    <BaseChart :option="chartOption" :height="180" />
+  </TechCard>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import TechCard from '../TechCard/index.vue'
+
+defineOptions({ name: 'GuardObjectTypeCard' })
+
+interface GuardItem {
+  guardType: string
+  count: string | number
+  name?: string
+}
+
+interface Props {
+  guardList: GuardItem[]
+}
+
+const props = defineProps<Props>()
+
+// 提取分类名称和数量
+const categories = computed(() => props.guardList.map((item) => item.name))
+const counts = computed(() => props.guardList.map((item) => Number(item.count)))
+
+// 配色方案
+const colorPalette = ['#f39c12', '#2ecc71', '#3498db', '#e74c3c', '#9b59b6', '#1abc9c']
+
+const chartOption = computed(() => ({
+  tooltip: {
+    trigger: 'item',
+    axisPointer: { type: 'shadow' },
+    formatter: '{b}: {c} 个',
+  },
+  grid: {
+    left: 40,
+    right: 20,
+    top: 20,
+    bottom: 60,
+  },
+  xAxis: {
+    type: 'category',
+    data: categories.value,
+    axisLabel: {
+      color: '#9cc5e0',
+      interval: 0,
+      fontSize: 10,
+      margin: 10,
+      formatter: (value: string) => {
+        const lines = value.length > 4 ? value.match(/.{1,2}/g) : [value]
+        return lines?.map((line) => `{label|${line}}`).join('\n')
+      },
+      rich: {
+        label: {
+          lineHeight: 14,
+        },
+      },
+    },
+    axisTick: { show: false },
+    axisLine: { show: false },
+  },
+  yAxis: {
+    type: 'value',
+    axisLabel: { color: '#9cc5e0' },
+    splitLine: { show: false },
+  },
+  series: [
+    {
+      type: 'bar',
+      data: counts.value,
+      barWidth: 30,
+      itemStyle: {
+        color: (params: { dataIndex: number }) => {
+          return colorPalette[params.dataIndex % colorPalette.length]
+        },
+      },
+      label: {
+        show: true,
+        position: 'top',
+        color: '#00f0ff',
+        fontSize: 12,
+      },
+    },
+  ],
+}))
+</script>
+
+<style scoped lang="less">
+.card-title {
+  font-size: 16px;
+  font-weight: bold;
+  color: #00f0ff;
+  margin-bottom: 12px;
+  text-align: center;
+}
+</style>

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

@@ -0,0 +1,127 @@
+<template>
+  <TechCard class="chart-wrapper">
+    <template #extra>
+      <div class="header">
+        <div class="card-title">{{ title }}</div>
+        <div classs="card-extra"><slot name="extra"></slot></div>
+      </div>
+    </template>
+    <BaseChart :option="chartOption" :height="155" />
+    <div v-if="loading" class="loading-mask"> <a-spin /> </div>
+  </TechCard>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import * as echarts from 'echarts'
+import TechCard from '../TechCard/index.vue'
+
+defineOptions({ name: 'HistoryChartCard' })
+
+interface StatInfo {
+  lable: string
+  count: number
+}
+
+const props = defineProps<{
+  title: string
+  dayStatInfo: StatInfo[]
+  monthStatInfo: StatInfo[]
+  color: string
+  seriesName: string
+  mode: 'day' | 'month'
+  loading?: boolean
+}>()
+
+const chartData = computed(() => {
+  if (props.mode === 'day') {
+    const dates = props.dayStatInfo.map((item) => item.lable)
+    const values = props.dayStatInfo.map((item) => item.count ?? 0)
+    return { dates, values }
+  } else {
+    const dates = props.monthStatInfo.map((item) => item.lable)
+    const values = props.monthStatInfo.map((item) => item.count ?? 0)
+    return { dates, values }
+  }
+})
+
+const chartOption = computed(() => ({
+  grid: { top: 10, right: 20, bottom: 30, left: 30 },
+  tooltip: { trigger: 'item', axisPointer: { type: 'shadow' } },
+  xAxis: {
+    type: 'category',
+    data: chartData.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: props.seriesName,
+      type: props.mode === 'day' ? 'line' : 'bar',
+      smooth: props.mode === 'day',
+      symbol: 'circle',
+      symbolSize: 8,
+      data: chartData.value.values,
+      itemStyle: { color: props.color },
+      lineStyle: {
+        width: 3,
+        color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
+          { offset: 0, color: props.color },
+          { offset: 1, color: props.color },
+        ]),
+      },
+      areaStyle:
+        props.mode === 'day'
+          ? {
+              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                { offset: 0, color: `${props.color}4D` },
+                { offset: 1, color: `${props.color}1A` },
+              ]),
+            }
+          : undefined,
+    },
+  ],
+}))
+</script>
+
+<style scoped lang="less">
+.chart-wrapper {
+  position: relative;
+}
+
+.header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+  margin-bottom: 12px;
+}
+
+.card-title {
+  font-size: 16px;
+  font-weight: bold;
+  text-align: center;
+  color: v-bind(color);
+}
+
+.loading-mask {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.3);
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 10;
+}
+</style>

+ 210 - 0
src/views/dashboard/components/MonitorPeopleCountCard/index.vue

@@ -0,0 +1,210 @@
+<template>
+  <TechCard class="people-detected-card">
+    <div class="card-header">
+      <div class="title">检测到人数</div>
+    </div>
+
+    <div class="matrix-wrapper">
+      <div class="scan-overlay" />
+      <div class="matrix-scroll">
+        <div class="matrix-grid">
+          <div
+            v-for="(item, index) in props.limit"
+            :key="index"
+            class="person-icon"
+            :class="{ active: detectedCount > props.limit || index < detectedCount }"
+          >
+            <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>
+
+    <div class="footer">
+      <div class="label">已检测人数</div>
+      <div class="count">{{ detectedCount }}人</div>
+    </div>
+  </TechCard>
+</template>
+
+<script setup lang="ts">
+import TechCard from '../TechCard/index.vue'
+
+defineOptions({ name: 'MonitorPeopleCountCard' })
+
+type Props = {
+  detectedCount: number
+  limit?: number // 限制显示的人数
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  detectedCount: 0,
+  limit: 28,
+})
+</script>
+
+<style scoped lang="less">
+.people-detected-card > {
+  :deep(.card-content) {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    justify-content: space-between;
+  }
+}
+.card-header {
+  text-align: center;
+  margin-bottom: 14px;
+
+  .title {
+    font-size: 16px;
+    font-weight: bold;
+    color: #00f0ff;
+  }
+}
+
+.matrix-wrapper {
+  position: relative;
+  padding: 12px 0;
+  min-height: 100px;
+  max-height: 144px;
+  overflow: hidden;
+}
+
+.scan-overlay {
+  position: absolute;
+  top: 0;
+  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-scroll {
+  height: 100%;
+  overflow-y: auto;
+
+  &::-webkit-scrollbar {
+    width: 4px;
+  }
+
+  &::-webkit-scrollbar-track {
+    background: transparent;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background-color: transparent;
+    border-radius: 2px;
+  }
+
+  &:hover::-webkit-scrollbar-thumb {
+    background-color: #00f0ff;
+  }
+
+  &::-webkit-scrollbar-button {
+    display: none;
+    height: 0;
+    width: 0;
+  }
+
+  scrollbar-width: thin;
+  scrollbar-color: transparent transparent;
+
+  &:hover {
+    scrollbar-color: #00f0ff transparent;
+  }
+}
+
+.matrix-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(30px, 1fr));
+  grid-auto-rows: 30px;
+  gap: 10px;
+}
+
+.person-icon {
+  width: 100%;
+  height: 36px;
+  position: relative;
+
+  .person-svg {
+    width: 100%;
+    height: 100%;
+    stroke-width: 3.5;
+    fill: none;
+    stroke: #00ff9f;
+    opacity: 0.3;
+    transition: opacity 0.3s ease;
+    filter: drop-shadow(0 0 2px #00ff9f);
+  }
+}
+
+.person-icon.active .person-svg {
+  stroke: #00ff9f;
+  opacity: 1;
+  filter: drop-shadow(0 0 6px #00ff9f);
+}
+
+@keyframes radarPulse {
+  0% {
+    transform: scale(1);
+    opacity: 1;
+    filter: drop-shadow(0 0 4px #00ff9f);
+  }
+  50% {
+    transform: scale(1.3);
+    opacity: 0.6;
+    filter: drop-shadow(0 0 12px #00ff9f);
+  }
+  100% {
+    transform: scale(1);
+    opacity: 1;
+    filter: drop-shadow(0 0 4px #00ff9f);
+  }
+}
+
+.person-icon.active:hover .person-svg {
+  animation: radarPulse 1.5s ease-in-out infinite;
+}
+
+.footer {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
+  font-size: 14px;
+  color: #9cc5e0;
+
+  .count {
+    font-weight: bold;
+    color: #00f0ff;
+    font-size: 16px;
+  }
+}
+</style>

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

@@ -1,86 +0,0 @@
-<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>

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

@@ -1,127 +0,0 @@
-<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>

+ 52 - 0
src/views/dashboard/components/RollingNumber/index.vue

@@ -0,0 +1,52 @@
+<template>
+  <div class="roll-number">
+    <DigitRoll
+      v-for="(d, idx) in digits"
+      :key="idx"
+      :digit="d"
+      :delay="idx * stagger"
+      :duration="duration"
+      :height="itemHeight"
+      :width="itemWidth"
+      :initialRoll="true"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import DigitRoll from '../DigitRoll/index.vue'
+
+defineOptions({
+  name: 'RollingNumber',
+})
+
+const props = defineProps<{
+  value: number
+  itemHeight?: number
+  itemWidth?: number
+  duration?: number
+  stagger?: number
+}>()
+
+const itemHeight = props.itemHeight ?? 60
+const itemWidth = props.itemWidth ?? 40
+const duration = props.duration ?? 900
+const stagger = props.stagger ?? 180
+
+const digits = computed(() => {
+  const s = String(Math.max(0, Math.floor(props.value ?? 0)))
+  return s.split('').map(Number)
+})
+</script>
+
+<style scoped lang="less">
+.roll-number {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  justify-content: center;
+  min-width: 100px;
+  padding: 0 8px;
+}
+</style>

+ 12 - 6
src/views/dashboard/components/TechCard/index.vue

@@ -5,6 +5,10 @@
     <div class="corner bottom-left"></div>
     <div class="corner bottom-right"></div>
 
+    <div class="card-header">
+      <slot name="extra" />
+    </div>
+
     <div class="card-content">
       <slot />
     </div>
@@ -23,17 +27,12 @@ defineOptions({ name: 'TechCard' })
   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;
@@ -68,6 +67,13 @@ defineOptions({ name: 'TechCard' })
     animation: rainbowBorder 3s linear infinite;
   }
 
+  .card-header {
+    display: flex;
+    margin-bottom: 8px;
+    z-index: 1;
+    position: relative;
+  }
+
   .card-content {
     position: relative;
     z-index: 1;

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

@@ -1,107 +0,0 @@
-<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>

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

@@ -1,832 +0,0 @@
-<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>

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

@@ -1,68 +0,0 @@
-<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>

+ 638 - 89
src/views/dashboard/index.vue

@@ -1,42 +1,124 @@
 <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="community-name">
+        <div class="fixedName"> 智慧大屏</div>
+        <div class="tenantName">{{ tenantName }}</div>
+      </div>
+      <div class="running-days"
+        >已安全守护
+        <RollingNumber :value="todayData.systemGuardDay" :itemHeight="50" :itemWidth="40" />
+        天</div
+      >
+
+      <div v-if="isSuperAdmin" class="tenant-switcher">
+        <div class="time-info">{{ currentTime }}</div>
+        <a-select
+          v-model:value="selectedTenant"
+          placeholder="请选择"
+          style="width: 150px"
+          :options="tenantList"
+          :getPopupContainer="(trigger: HTMLElement) => trigger.parentNode"
+          @change="handleTenantChange"
+        />
+      </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 class="block custom-scroll">
+        <div class="data-row">
+          <DeviceOnlineRateCard
+            :online-count="todayData.onlineCount"
+            :device-count="todayData.deviceCount"
+          ></DeviceOnlineRateCard>
+          <MonitorPeopleCountCard :detectedCount="todayData.detectedCount"></MonitorPeopleCountCard>
+        </div>
+        <div class="data-row">
+          <GuardObjectTypeCard :guardList="todayData.guardList"></GuardObjectTypeCard>
+          <AlertFallCompareCard
+            :fall-count="todayData.fallingCount"
+            :alert-count="todayData.alarmCount"
+          ></AlertFallCompareCard>
         </div>
-        <DeviceLocationCard></DeviceLocationCard>
+        <DeviceLocationCard :data="todayData.installPositionList"></DeviceLocationCard>
       </div>
-      <div class="block block-center">
+      <div class="block block-center custom-scroll">
         <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
+            class="map-container-wrapper"
+            :style="{ transform: `scale(${scale})`, transformOrigin: 'center center' }"
+            @click="toDeviceList"
+          >
+            <img class="map-img" src="./assets/img/map.jpg" alt="" />
+            <div class="map-label building-1" @click="toDeviceList">1号楼</div>
+            <div class="map-label building-2" @click="toDeviceList">2号楼</div>
+            <div class="map-label building-3" @click="toDeviceList">3号楼</div>
+            <div class="map-label building-4" @click="toDeviceList">4号楼</div>
+          </div>
         </div>
+        <a-space class="zoom-controls">
+          <ZoomOutOutlined @click="zoomOut" />
+          <RedoOutlined @click="zoomReset" />
+          <ZoomInOutlined @click="zoomIn" />
+        </a-space>
       </div>
-      <div class="block">
+      <div class="block custom-scroll" style="padding: 10px">
         <div class="data-row">
-          <DeviceAgeCard></DeviceAgeCard>
-          <ElderActivityCard></ElderActivityCard>
-          <!-- <DetectionTargetCard></DetectionTargetCard> -->
+          <GuardObjectAgeCard :ageList="todayData.ageList"></GuardObjectAgeCard>
+          <ElderActivityCard :activity-rate="todayData.activeRate"></ElderActivityCard>
         </div>
 
-        <AlarmHistoryCard style="margin-bottom: 12px"></AlarmHistoryCard>
-        <FallingHistoryCard></FallingHistoryCard>
+        <div class="data-line">
+          <HistoryChartCard
+            title="历史告警统计"
+            :dayStatInfo="historyData.alarmHistoryData.dayStatInfo"
+            :monthStatInfo="historyData.alarmHistoryData.monthStatInfo"
+            :mode="alarmMode"
+            :loading="alarmLoading"
+            color="#f39c12"
+            seriesName="告警次数"
+          >
+            <template #extra>
+              <div class="toggle-group">
+                <button
+                  :class="['alarm-button', { active: alarmMode === 'day' }]"
+                  @click="changeAlarmMode('day')"
+                  >最近7天</button
+                >
+                <button
+                  :class="['alarm-button', { active: alarmMode === 'month' }]"
+                  @click="changeAlarmMode('month')"
+                  >最近180天</button
+                >
+              </div>
+            </template>
+          </HistoryChartCard>
+
+          <HistoryChartCard
+            title="历史跌倒统计"
+            :dayStatInfo="historyData.fallHistoryData.dayStatInfo"
+            :monthStatInfo="historyData.fallHistoryData.monthStatInfo"
+            :mode="fallMode"
+            :loading="fallLoading"
+            color="#e74c3c"
+            seriesName="跌倒次数"
+          >
+            <template #extra>
+              <div class="toggle-group">
+                <button
+                  :class="['fall-button', { active: fallMode === 'day' }]"
+                  @click="changeFallMode('day')"
+                  >最近7天</button
+                >
+                <button
+                  :class="['fall-button', { active: fallMode === 'month' }]"
+                  @click="changeFallMode('month')"
+                  >最近180天</button
+                >
+              </div>
+            </template>
+          </HistoryChartCard>
+        </div>
       </div>
     </div>
     <div class="dashboard-footer">合肥雷能信息技术有限公司 © 2025 All Rights Reserved.</div>
@@ -44,25 +126,44 @@
 </template>
 
 <script setup lang="ts">
-// import ScreenPage from './components/screen/index.vue'
-import { ref, onMounted, onUnmounted } from 'vue'
+import { ref, onMounted, onUnmounted, watch, computed, watchEffect } 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 MonitorPeopleCountCard from './components/MonitorPeopleCountCard/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'
+import GuardObjectAgeCard from './components/GuardObjectAgeCard/index.vue'
+import GuardObjectTypeCard from './components/GuardObjectTypeCard/index.vue'
+import HistoryChartCard from './components/HistoryChartCard/index.vue'
+import RollingNumber from './components/RollingNumber/index.vue'
+import { useUserStore } from '@/stores/user'
+import type { TodayData } from './types'
+import { ZoomInOutlined, ZoomOutOutlined, RedoOutlined } from '@ant-design/icons-vue'
+import { useResponsiveLayout } from '@/utils/chartManager'
+import { useDashboardPolling } from '@/hooks/useDashboardPolling'
+import { useDict } from '@/hooks/useDict'
+import * as tenantAPI from '@/api/tenant'
+import type {
+  StatsHomeScreenAlarmHistory,
+  StatsHomeScreenFallHistory,
+  StatsHomeScreenQueryData,
+} from '@/api/stats/types'
+
+const userStore = useUserStore()
+
+const tenantName = computed(() => {
+  if (isSuperAdmin.value) {
+    const selected = tenantList.value.find((item) => item.value === selectedTenant.value)
+    return selected?.label ?? '未知租户'
+  } else {
+    return userStore.userInfo.tenantName ?? '雷能技术'
+  }
+})
 
 defineOptions({
   name: 'DashboardPage',
 })
 
-// 使用响应式布局工具
-
-// 格式化当前时间
 const currentTime = ref('')
 const formatTime = () => {
   const now = new Date()
@@ -77,24 +178,276 @@ const formatTime = () => {
 
 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,
-// })
+const todayData = ref<TodayData>({
+  deviceCount: 0, // 设备总数
+  onlineCount: 0, // 在线设备数
+  systemGuardDay: 0, // 系统守护天数
+  alarmCount: 0, // 告警次数
+  fallingCount: 0, // 跌倒次数
+  detectedCount: 0, // 检测人数
+  activeRate: 0, // 活跃度
+  guardList: [], // 检测对象
+  ageList: [], // 年龄层次
+  installPositionList: [], // 安装位置
+})
+
+useResponsiveLayout()
+
+const isSuperAdmin = ref(
+  userStore.userInfo.userType === 'admin' || userStore.userInfo.userType === 'manager'
+)
+
+const selectedTenant = ref<string | number>(userStore.userInfo.tenantId || '')
+const tenantList = ref<{ label: string; value: string | number }[]>([])
+const currentTenantId = ref<string | number>(selectedTenant.value)
+
+const fetchTenantList = async () => {
+  if (!isSuperAdmin.value) {
+    currentTenantId.value = userStore.userInfo.tenantId || ''
+    return
+  }
+
+  const res = await tenantAPI.queryTenant({
+    pageNo: 1,
+    pageSize: 1000,
+  })
+
+  tenantList.value = res.data.rows.map((item) => ({
+    label: item.tenantName ?? '',
+    value: item.tenantId ?? '',
+  }))
+
+  selectedTenant.value = tenantList.value[0]?.value || ''
+  currentTenantId.value = selectedTenant.value
+}
+
+fetchTenantList()
+
+const handleTenantChange = () => {
+  if (selectedTenant.value) {
+    currentTenantId.value = selectedTenant.value
+  }
+}
+
+type RawDayItem = { date: string; fallingCount?: number; alarmCount?: number }
+type RawMonthItem = { month: string; fallingCount?: number; alarmCount?: number }
+
+const todayScreenData = ref<StatsHomeScreenQueryData | null>(null)
+const fallHistoryData = ref<StatsHomeScreenFallHistory | null>(null)
+const alarmHistoryData = ref<StatsHomeScreenAlarmHistory | null>(null)
+let updateFallQueryType: ((mode: 'day' | 'month') => Promise<void>) | null = null
+let updateAlarmQueryType: ((mode: 'day' | 'month') => Promise<void>) | null = null
+
+let pollingInstance: ReturnType<typeof useDashboardPolling> | null = null
+
+watch(
+  currentTenantId,
+  (newTenantId) => {
+    if (pollingInstance) {
+      pollingInstance.stop()
+      pollingInstance = null
+      alarmMode.value = 'day'
+      fallMode.value = 'day'
+    }
+
+    if (newTenantId) {
+      pollingInstance = useDashboardPolling({ tenantId: String(newTenantId) })
+      pollingInstance.start()
+
+      // 赋值响应式数据
+      watchEffect(() => {
+        todayScreenData.value = pollingInstance?.todayScreenData.value ?? null
+        fallHistoryData.value = pollingInstance?.fallHistoryData.value ?? null
+        alarmHistoryData.value = pollingInstance?.alarmHistoryData.value ?? null
+      })
+
+      updateFallQueryType = pollingInstance.updateFallQueryType
+      updateAlarmQueryType = pollingInstance.updateAlarmQueryType
+    }
+  },
+  { immediate: true }
+)
+
+const { fetchDict: fetchDictGuardianship, dictNameMap: guardTypeNameMap } =
+  useDict('guardianship_type')
+
+const { fetchDict: fetchDictInstallPosition, dictNameMap: installPositionNameMap } =
+  useDict('install_position')
+
+Promise.all([fetchDictGuardianship(), fetchDictInstallPosition()])
+
+watch(
+  () => todayScreenData.value,
+  (val) => {
+    console.log('🚀🚀🚀 todayScreenData更新了', val)
+    todayData.value.activeRate = val?.activeRate ?? 0
+    todayData.value.detectedCount = val?.detectedCount ?? 0
+    todayData.value.fallingCount = val?.fallingCount ?? 0
+    todayData.value.alarmCount = val?.alarmCount ?? 0
+    todayData.value.systemGuardDay = val?.systemGuardDay ?? 0
+    todayData.value.onlineCount = val?.onlineCount ?? 0
+    todayData.value.deviceCount = val?.deviceCount ?? 0
+    todayData.value.ageList = (val?.ageList && val?.ageList.filter((item) => item.count > 0)) ?? []
+    todayData.value.installPositionList =
+      (val?.installPositionList &&
+        val?.installPositionList.map((item) => ({
+          ...item,
+          name:
+            installPositionNameMap.value[
+              item.installPosition as keyof typeof installPositionNameMap.value
+            ] || '未知',
+          names: installPositionNameMap.value,
+        }))) ??
+      []
+    todayData.value.guardList =
+      (val?.guardList &&
+        val?.guardList.map((item) => ({
+          ...item,
+          name:
+            guardTypeNameMap.value[item.guardType as keyof typeof guardTypeNameMap.value] || '未知',
+          names: guardTypeNameMap.value,
+        }))) ??
+      []
+  },
+  { immediate: true, deep: true }
+)
+
+type StatInfo = { lable: string; count: number }
+type HistoryData = { monthStatInfo: StatInfo[]; dayStatInfo: StatInfo[] }
+
+const historyData = ref<{
+  fallHistoryData: HistoryData
+  alarmHistoryData: HistoryData
+}>({
+  fallHistoryData: { monthStatInfo: [], dayStatInfo: [] },
+  alarmHistoryData: { monthStatInfo: [], dayStatInfo: [] },
+})
+
+// 通用转换函数
+const transformStatInfo = (
+  source: RawDayItem[] | RawMonthItem[],
+  labelKey: 'date' | 'month',
+  countKey: 'fallingCount' | 'alarmCount'
+): StatInfo[] => {
+  if (labelKey === 'date') {
+    return (source as RawDayItem[]).map((item) => ({
+      lable: item.date,
+      count: item[countKey] ?? 0,
+    }))
+  } else {
+    return (source as RawMonthItem[]).map((item) => ({
+      lable: item.month,
+      count: item[countKey] ?? 0,
+    }))
+  }
+}
+
+// 监听跌倒数据
+watch(
+  () => fallHistoryData.value,
+  (val) => {
+    console.log('🚀🚀🚀 fallHistoryData 更新了', val)
+    historyData.value.fallHistoryData.dayStatInfo = transformStatInfo(
+      val?.dayStatInfo ?? [],
+      'date',
+      'fallingCount'
+    )
+    historyData.value.fallHistoryData.monthStatInfo = transformStatInfo(
+      val?.monthStatInfo ?? [],
+      'month',
+      'fallingCount'
+    )
+  },
+  { immediate: true, deep: true }
+)
+
+// 监听告警数据
+watch(
+  () => alarmHistoryData.value,
+  (val) => {
+    console.log('🚀🚀🚀 alarmHistoryData 更新了', val)
+    historyData.value.alarmHistoryData.dayStatInfo = transformStatInfo(
+      val?.dayStatInfo ?? [],
+      'date',
+      'alarmCount'
+    )
+    historyData.value.alarmHistoryData.monthStatInfo = transformStatInfo(
+      val?.monthStatInfo ?? [],
+      'month',
+      'alarmCount'
+    )
+  },
+  { immediate: true, deep: true }
+)
+
+const alarmMode = ref<'day' | 'month'>('day')
+const fallMode = ref<'day' | 'month'>('day')
+
+const alarmLoading = ref(false)
+const fallLoading = ref(false)
+
+const changeAlarmMode = async (mode: 'day' | 'month') => {
+  if (alarmMode.value !== mode) {
+    alarmMode.value = mode
+    alarmLoading.value = true
+    await updateAlarmQueryType?.(mode)
+    alarmLoading.value = false
+  }
+}
+
+const changeFallMode = async (mode: 'day' | 'month') => {
+  if (fallMode.value !== mode) {
+    fallMode.value = mode
+    fallLoading.value = true
+    await updateFallQueryType?.(mode)
+    fallLoading.value = false
+  }
+}
+
+const toDeviceList = () => {
+  window.open('/device/list', '_blank')
+}
+
+const scale = ref(0.8)
+
+const zoomIn = () => {
+  scale.value += 0.1
+  if (scale.value > 1) {
+    scale.value = 1
+  }
+}
+
+const zoomReset = () => {
+  scale.value = 0.7
+}
+
+const zoomOut = () => {
+  scale.value = Math.max(0.5, scale.value - 0.1)
+}
+
+onMounted(() => {
+  window.addEventListener('resize', handleResize)
+})
+onUnmounted(() => {
+  window.removeEventListener('resize', handleResize)
+})
+
+const handleResize = () => {
+  if (window.innerWidth < 1400) {
+    scale.value = 0.5
+  } else if (window.innerWidth < 1600) {
+    scale.value = 0.6
+  } else {
+    scale.value = 0.7
+  }
+}
 </script>
 
 <style scoped lang="less">
@@ -139,9 +492,7 @@ onMounted(() => {
     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 {
@@ -153,17 +504,59 @@ onMounted(() => {
       text-shadow: 0 0 10px rgba(109, 228, 255, 0.5);
       letter-spacing: 2px;
       flex-shrink: 0;
+      display: flex;
+      align-items: center;
+      .fixedName {
+        margin-right: 10px;
+      }
     }
 
     .running-days {
-      font-size: 22px;
-      color: @secondary-color;
-      text-shadow: 0 0 8px rgba(109, 228, 255, 0.7);
+      position: absolute;
+      top: 70%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      display: flex;
+      align-items: center;
+      justify-content: center;
       flex-shrink: 0;
+
+      font-size: 24px;
+      font-family: 'Orbitron', 'Segoe UI', sans-serif;
+      color: #6de4ff;
+      text-shadow:
+        0 0 8px rgba(109, 228, 255, 0.8),
+        0 0 16px rgba(109, 228, 255, 0.4);
+
+      padding: 12px 24px;
+      border: 1px solid rgba(109, 228, 255, 0.4);
+      border-radius: 10px;
+      background: rgba(20, 30, 60, 0.3);
+      backdrop-filter: blur(6px);
+      box-shadow:
+        0 0 12px rgba(109, 228, 255, 0.3),
+        inset 0 0 6px rgba(109, 228, 255, 0.2);
+
+      animation: pulse-glow 2s infinite ease-in-out;
+      z-index: 10;
+    }
+
+    @keyframes pulse-glow {
+      0%,
+      100% {
+        box-shadow:
+          0 0 12px rgba(109, 228, 255, 0.3),
+          inset 0 0 6px rgba(109, 228, 255, 0.2);
+      }
+      50% {
+        box-shadow:
+          0 0 20px rgba(109, 228, 255, 0.6),
+          inset 0 0 10px rgba(109, 228, 255, 0.4);
+      }
     }
 
     .time-info {
-      font-size: 18px;
+      font-size: 20px;
       color: @primary-color;
       flex-shrink: 0;
     }
@@ -194,40 +587,50 @@ onMounted(() => {
     border-radius: 8px;
     display: flex;
     gap: 20px;
-    overflow: hidden;
     min-height: 0;
+    overflow-y: auto;
 
     .block {
-      min-height: 500px;
       flex-grow: 1;
       flex-shrink: 1;
-      flex: 1 1 300px;
+      flex-basis: 400px;
       display: flex;
       flex-direction: column;
       position: relative;
-      overflow: hidden;
+      overflow-y: auto;
     }
 
     .block-center {
-      flex: 1 1 600px;
+      flex-grow: 1;
+      flex-shrink: 1;
+      flex-basis: 300px;
       display: flex;
-      min-width: min-content;
       position: relative;
 
       .map-container {
         position: relative;
-        display: inline-block;
+        display: flex;
         border-radius: 6px;
         height: 100%;
-        width: 100%;
+        align-items: center;
+        justify-content: center;
         background: #0b173f;
         overflow: hidden;
         user-select: none;
+        &-wrapper {
+          position: relative;
+          cursor: pointer;
+        }
+      }
+      .zoom-controls {
+        position: absolute;
+        bottom: 10px;
+        right: 10px;
+        color: @text-color;
+        font-size: 20px;
       }
 
       .map-img {
-        width: 100%;
-        height: 100%;
         object-fit: contain;
         display: block;
         border-radius: 6px;
@@ -240,66 +643,67 @@ onMounted(() => {
         border: 1px solid @border-color;
         border-radius: 4px;
         padding: 4px 8px;
-        font-size: 14px;
+        font-size: 20px;
         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;
+        cursor: pointer;
       }
 
       .building-1 {
-        top: 30%;
-        left: 30%;
+        top: 200px;
+        left: 200px;
+        animation: pulse 2s infinite;
       }
 
       .building-2 {
-        top: 50%;
-        left: 70%;
+        top: 250px;
+        left: 330px;
+        animation: pulse 2s infinite;
       }
 
       .building-3 {
-        top: 70%;
-        left: 50%;
+        top: 150px;
+        left: 450px;
+        animation: pulse 2s infinite;
       }
 
       .building-4 {
-        top: 40%;
-        left: 20%;
+        top: 350px;
+        left: 380px;
+        animation: pulse-red 2s infinite;
       }
 
       @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);
+          box-shadow: 0 0 0 12px rgba(77, 201, 230, 0);
         }
         100% {
-          transform: translate(-50%, -50%) scale(1);
           box-shadow: 0 0 0 0 rgba(77, 201, 230, 0);
         }
       }
+
+      @keyframes pulse-red {
+        0% {
+          box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.4);
+        }
+        70% {
+          box-shadow: 0 0 0 12px rgba(231, 76, 60, 0);
+        }
+        100% {
+          box-shadow: 0 0 0 0 rgba(231, 76, 60, 0);
+        }
+      }
     }
 
     .data-grid {
       display: grid;
-      grid-template-columns: 1fr 1fr;
+      grid-template-columns: 1fr 1fr 1fr;
       gap: 12px;
       margin-bottom: 12px;
     }
@@ -310,6 +714,12 @@ onMounted(() => {
       gap: 12px;
       margin-bottom: 12px;
     }
+
+    .data-line {
+      display: grid;
+      grid-template-columns: 1fr;
+      gap: 12px;
+    }
   }
 
   &-footer {
@@ -382,4 +792,143 @@ onMounted(() => {
     }
   }
 }
+
+.toggle-group {
+  display: flex;
+  gap: 8px;
+  justify-content: flex-end;
+
+  button {
+    background: none;
+    border: 1px solid;
+    padding: 4px 10px;
+    border-radius: 4px;
+    font-size: 12px;
+    cursor: pointer;
+
+    &.active {
+      color: #fff;
+    }
+  }
+
+  .alarm-button {
+    border-color: #f39c12;
+    color: #f39c12;
+
+    &.active {
+      background-color: #f39c12;
+      color: #fff;
+    }
+  }
+
+  .fall-button {
+    border-color: #e74c3c;
+    color: #e74c3c;
+
+    &.active {
+      background-color: #e74c3c;
+      color: #fff;
+    }
+  }
+}
+
+:deep(.tenant-switcher) {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  font-size: 14px;
+  .ant-select {
+    background-color: @panel-bg;
+
+    .ant-select-selector {
+      background-color: @panel-bg;
+      border-color: @border-color;
+      color: @text-color;
+      box-shadow: 0 0 5px @glow-color;
+      transition: all 0.3s ease;
+    }
+
+    &.ant-select-open .ant-select-selector {
+      border-color: @accent-color;
+      background-color: @panel-bg;
+
+      .ant-select-selection-item,
+      .ant-select-selection-placeholder {
+        color: @text-color !important;
+      }
+    }
+
+    .ant-select-arrow {
+      color: @text-color;
+    }
+  }
+
+  .ant-select-dropdown {
+    background-color: @bg-color;
+    color: @text-color;
+    border: 1px solid @border-color;
+    box-shadow: 0 0 10px @glow-color;
+    max-width: 180px;
+
+    .ant-select-item {
+      color: @text-color;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      transition: background-color 0.2s ease;
+
+      &.ant-select-item-option-selected {
+        background-color: @accent-color;
+        color: @text-color;
+      }
+
+      &:hover {
+        background-color: @border-color;
+        color: @text-color;
+      }
+    }
+  }
+}
+</style>
+
+<style lang="less">
+.custom-scroll {
+  overflow-y: auto;
+
+  &::-webkit-scrollbar {
+    width: 4px;
+  }
+
+  &::-webkit-scrollbar-track {
+    background: transparent;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background-color: transparent;
+    border-radius: 2px;
+  }
+
+  &:hover::-webkit-scrollbar-thumb {
+    background-color: #00f0ff;
+  }
+
+  &::-webkit-scrollbar-button {
+    display: none;
+    width: 0;
+    height: 0;
+  }
+
+  &::-webkit-scrollbar-button:single-button {
+    display: none;
+    width: 0;
+    height: 0;
+  }
+
+  scrollbar-width: thin;
+  scrollbar-color: transparent transparent;
+
+  &:hover {
+    scrollbar-color: #00f0ff transparent;
+  }
+}
 </style>

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

@@ -22,4 +22,7 @@ export type TodayData = Pick<
   | 'alarmCount'
   | 'detectedCount'
   | 'activeRate'
+  | 'guardList'
+  | 'ageList'
+  | 'installPositionList'
 >

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

@@ -252,7 +252,10 @@
         </div>
 
         <div v-if="selectedBlock" class="mapConfig">
-          <div class="mapConfig-header">子区域属性</div>
+          <div class="mapConfig-header">
+            <span class="title">子区域属性</span>
+            <span class="close" @click="closeSubregionAttr"><CloseOutlined /></span>
+          </div>
           <div class="mapConfig-item">
             <div class="mapConfig-item-label">X范围:</div>
             <div class="mapConfig-item-content">
@@ -368,6 +371,7 @@ import {
   ArrowRightOutlined,
   DeleteOutlined,
   QuestionCircleOutlined,
+  CloseOutlined,
 } from '@ant-design/icons-vue'
 import { getOriginPosition } from '@/utils'
 
@@ -744,6 +748,14 @@ const deleteFurnitureIcon = (nanoid: string) => {
   }
 }
 
+// 关闭子区域属性
+const closeSubregionAttr = () => {
+  blocks.value.forEach((item) => {
+    item.isActice = false
+  })
+  selectedBlock.value = null
+}
+
 // 删除家具床
 const deleteFurnitureBed = (nanoid: string) => {
   console.log('deleteFurnitureBed', nanoid)
@@ -813,10 +825,10 @@ const getContainerRect = () => {
 const handleMouseDown = (e: MouseEvent) => {
   if (!isEditDraggable.value) return
   console.log('handleMouseDown', e)
-  blocks.value.forEach((item) => {
-    item.isActice = false
-  })
-  selectedBlock.value = null
+  // blocks.value.forEach((item) => {
+  //   item.isActice = false
+  // })
+  // selectedBlock.value = null
   if (!isCreating.value) return
 
   const rect = getContainerRect()
@@ -936,39 +948,6 @@ 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 { originX, originY, originOffsetX, originOffsetY, radarX, radarY } = getOriginPosition(
   props.ranges,
   [currentDragItem.value?.left as number, currentDragItem.value?.top as number]
@@ -1004,37 +983,6 @@ const initRadarIcon = () => {
   })
 }
 
-// 保存家具配置
-// 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('保存所有配置')
@@ -1117,13 +1065,16 @@ const startResize = (block: BlockItem, e: MouseEvent) => {
 }
 
 const blockInputPressEnter = (e: Event, el: BlockItem, attr: string) => {
+  console.log('blockInputPressEnter', e, el, attr)
+  if (!el) return
   if (attr === 'startXx') {
     el.startXx = Number(el[attr as keyof BlockItem])
     el.x = el.startXx + originX
   }
   if (attr === 'stopXx') {
     el.stopXx = Number(el[attr as keyof BlockItem])
-    el.width = el.stopXx + originX - el.width
+    // el.width = el.stopXx + originX - el.width
+    el.width = el.stopXx - el.startXx
   }
 
   if (attr === 'startYy') {
@@ -1132,19 +1083,22 @@ const blockInputPressEnter = (e: Event, el: BlockItem, attr: string) => {
   }
   if (attr === 'stopYy') {
     el.stopYy = Number(el[attr as keyof BlockItem])
-    el.height = el.stopYy + originY - el.height
+    // el.height = el.stopYy + originY - el.height
+    el.height = el.stopYy - el.startYy
   }
 }
 
 const blockInputBlur = (e: Event, el: BlockItem, attr: string) => {
   console.log('blockInputBlur', e, el, attr)
+  if (!el) return
   if (attr === 'startXx') {
     el.startXx = Number(el[attr as keyof BlockItem])
     el.x = el.startXx + originX
   }
   if (attr === 'stopXx') {
     el.stopXx = Number(el[attr as keyof BlockItem])
-    el.width = el.stopXx + originX - el.width
+    // el.width = el.stopXx + originX - el.width
+    el.width = el.stopXx - el.startXx
   }
 
   if (attr === 'startYy') {
@@ -1153,7 +1107,8 @@ const blockInputBlur = (e: Event, el: BlockItem, attr: string) => {
   }
   if (attr === 'stopYy') {
     el.stopYy = Number(el[attr as keyof BlockItem])
-    el.height = el.stopYy + originY - el.height
+    // el.height = el.stopYy + originY - el.height
+    el.height = el.stopYy - el.startYy
   }
 }
 
@@ -1222,9 +1177,22 @@ const deleteBlockArea = (id: string) => {
   border-radius: 10px;
   padding: 12px;
   &-header {
-    font-size: 14px;
     margin-bottom: 10px;
-    font-weight: 600;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    .title {
+      font-size: 14px;
+      font-weight: 600;
+      line-height: 24px;
+    }
+    .close {
+      font-size: 14px;
+      color: #666;
+      cursor: pointer;
+      position: relative;
+      top: -5px;
+    }
   }
 
   &-item {

+ 63 - 4
src/views/device/detail/components/deviceBaseConfig/index.vue

@@ -31,11 +31,8 @@
           v-model:value="baseFormState.installPosition"
           placeholder="请选择安装位置"
           :style="inputStyle"
+          :options="installPositionOptions"
         >
-          <a-select-option value="Toilet">卫生间</a-select-option>
-          <a-select-option value="Bedroom">卧室</a-select-option>
-          <a-select-option value="LivingRoom">客厅</a-select-option>
-          <a-select-option value="Restaurant">餐厅</a-select-option>
         </a-select>
       </a-form-item>
 
@@ -191,6 +188,7 @@
           placeholder="请选择归属租户"
           :options="tenantOptions"
           :style="inputStyle"
+          allow-clear
         >
         </a-select>
       </a-form-item>
@@ -210,6 +208,32 @@
           </template>
         </a-input>
       </a-form-item>
+      <a-form-item label="监护对象年龄" name="guardAge">
+        <a-input
+          v-model:value.trim="baseFormState.guardAge"
+          placeholder="请输入监护对象年龄"
+          :style="inputStyle"
+        >
+          <template #suffix>
+            <a-tooltip>
+              <template #title>
+                <div>年龄范围: 1 - 120</div>
+              </template>
+              <info-circle-outlined style="color: rgba(0, 0, 0, 0.45)" />
+            </a-tooltip>
+          </template>
+        </a-input>
+      </a-form-item>
+      <a-form-item label="监护对象类型" name="guardType">
+        <a-select
+          v-model:value="baseFormState.guardType"
+          placeholder="请选择监护对象类型"
+          :options="guardTypeOptions"
+          :style="inputStyle"
+          allow-clear
+        >
+        </a-select>
+      </a-form-item>
 
       <div class="footer" :style="{ marginLeft: '100px' }">
         <a-space>
@@ -236,6 +260,7 @@ import * as deviceApi from '@/api/device'
 import * as tenantAPI from '@/api/tenant'
 import type { TenantItem } from '@/api/tenant/types'
 import { useUserStore } from '@/stores/user'
+import { useDict } from '@/hooks/useDict'
 
 defineOptions({
   name: 'deviceBaseConfig',
@@ -271,6 +296,8 @@ interface BaseFormState {
   northAngle: NorthAngle // 正北向夹角
   tenantId: ID // 租户id
   fallingConfirm: ID // 跌倒确认
+  guardAge?: ID // 监护对象年龄
+  guardType?: ID // 监护对象类型
 }
 
 const spinning = ref(false)
@@ -288,6 +315,8 @@ const baseFormState = reactive<BaseFormState>({
   northAngle: 0,
   tenantId: null,
   fallingConfirm: 53,
+  guardAge: null,
+  guardType: null,
 })
 
 // 范围输入框尺寸
@@ -328,6 +357,8 @@ const saveBaseConfig = async () => {
             Number(baseFormState?.fallingConfirm) > 0
               ? Number(baseFormState?.fallingConfirm)
               : null,
+          age: baseFormState?.guardAge ? Number(baseFormState?.guardAge) : null,
+          guardianshipType: baseFormState?.guardType ? String(baseFormState?.guardType) : null,
         })
         saveBaseLoading.value = false
         message.success('保存成功')
@@ -383,6 +414,24 @@ const rules: Record<string, Rule[]> = {
       trigger: ['change', 'blur'],
     },
   ],
+  guardAge: [
+    {
+      validator: (_rule: Rule, value: string) => {
+        if (!value) {
+          return Promise.resolve()
+        }
+        if (!/^\d+$/.test(value)) {
+          return Promise.reject(new Error('必须为整数'))
+        }
+        const num = parseInt(value, 10)
+        if (num < 1 || num > 120) {
+          return Promise.reject(new Error('年龄范围: 1 - 120'))
+        }
+        return Promise.resolve()
+      },
+      trigger: ['change', 'blur'],
+    },
+  ],
   // xRangeStart: [
   //   {
   //     required: true,
@@ -449,6 +498,14 @@ const fetchTenantList = async () => {
 }
 fetchTenantList()
 
+const { dictList: guardTypeOptions, fetchDict: fetchGuardTypeOptions } =
+  useDict('guardianship_type')
+fetchGuardTypeOptions()
+
+const { dictList: installPositionOptions, fetchDict: fetchDictInstallPosition } =
+  useDict('install_position')
+fetchDictInstallPosition()
+
 // 获取设备回显数据
 const fetchDeviceBaseInfo = async () => {
   console.log('fetchDeviceDetail', props)
@@ -475,6 +532,8 @@ const fetchDeviceBaseInfo = async () => {
     baseFormState.northAngle = res.data.northAngle
     baseFormState.tenantId = res.data.tenantId
     baseFormState.fallingConfirm = res.data.fallingConfirm
+    baseFormState.guardAge = res.data.age
+    baseFormState.guardType = res.data.guardianshipType
   } catch (error) {
     console.error('❌获取设备详情失败', error)
   }

+ 55 - 162
src/views/device/detail/index.vue

@@ -1,12 +1,7 @@
 <template>
   <a-spin :spinning="spinning">
     <div class="deviceDetail">
-      <info-card
-        title="实时点位"
-        :class="[
-          furnitureItems && furnitureItems.some((item) => item.type === 'bed') ? 'pointCard' : '',
-        ]"
-      >
+      <info-card title="实时点位">
         <template #extra>
           <a-space>
             <a-button type="primary" size="small" @click="roomConfigHandler('area')">
@@ -22,70 +17,23 @@
           style="margin-bottom: 10px"
         />
         <div class="pointMap">
-          <div
-            class="radarArea"
-            :style="{
-              width: `${detailState?.length || 400}px`,
-              height: `${detailState?.width || 400}px`,
-            }"
-          >
-            <furniture-icon
-              v-for="(item, index) in furnitureItems"
-              :key="index"
-              :icon="item.type"
-              :width="item.width"
-              :height="item.length"
-              :style="{
-                left: `${item.left}px`,
-                top: `${item.top}px`,
-                position: 'absolute',
-                rotate: `${item.rotate}deg`,
-                cursor: 'default',
-                pointerEvents: 'none',
-              }"
-              :draggable="false"
-            />
-          </div>
-
-          <template v-if="targets && Object.keys(targets).length > 0">
-            <template v-for="t in targets" :key="t.id">
-              <div
-                class="target-dot"
-                :style="{
-                  position: 'absolute',
-                  width: '18px',
-                  height: '18px',
-                  background: t.id === 0 ? 'red' : t.id === 1 ? 'blue' : 'green',
-                  borderRadius: '50%',
-                  transform: `translate3d(${t.displayX + 200}px, ${-t.displayY + 200}px, 0) translate(-50%, -50%)`,
-                  zIndex: 10,
-                  transition: 'transform 1s linear',
-                  willChange: 'transform',
-                }"
-              >
-                <span
-                  style="
-                    color: #fff;
-                    font-size: 12px;
-                    font-weight: 600;
-                    position: absolute;
-                    left: 50%;
-                    top: 50%;
-                    transform: translate(-50%, -50%);
-                    pointer-events: none;
-                  "
-                >
-                  {{ t.id + 1 }}
-                </span>
-              </div>
-            </template>
-          </template>
-
-          <div
-            v-if="furnitureItems && furnitureItems.some((item) => item.type === 'bed')"
-            class="breathLine"
-          >
-            <BreathLineChart :data="breathRpmList"></BreathLineChart>
+          <RadarView
+            :angle="detailState.northAngle"
+            :coordinates="[
+              detailState.xxStart,
+              detailState.xxEnd,
+              detailState.yyStart,
+              detailState.yyEnd,
+            ]"
+            :furnitureItems="furnitureItems"
+            :targets="Object.values(targets)"
+          ></RadarView>
+
+          <div class="breathLine">
+            <BreathLineChart
+              v-if="furnitureItems && furnitureItems.some((item) => item.type === 'bed')"
+              :data="breathRpmList"
+            ></BreathLineChart>
           </div>
         </div>
       </info-card>
@@ -94,64 +42,17 @@
         <div class="fullView">
           <div class="pointTitle">实时点位图</div>
           <div class="pointMap">
-            <div
-              class="radarArea"
-              :style="{
-                width: `${detailState?.length || 400}px`,
-                height: `${detailState?.width || 400}px`,
-              }"
-            >
-              <furniture-icon
-                v-for="(item, index) in furnitureItems"
-                :key="index"
-                :icon="item.type"
-                :width="item.width"
-                :height="item.length"
-                :style="{
-                  left: `${item.left}px`,
-                  top: `${item.top}px`,
-                  position: 'absolute',
-                  rotate: `${item.rotate}deg`,
-                  cursor: 'default',
-                  pointerEvents: 'none',
-                }"
-                :draggable="false"
-              />
-            </div>
-
-            <template v-if="targets && Object.keys(targets).length > 0">
-              <template v-for="t in targets" :key="t.id">
-                <div
-                  class="target-dot"
-                  :style="{
-                    position: 'absolute',
-                    width: '18px',
-                    height: '18px',
-                    background: t.id === 0 ? 'red' : t.id === 1 ? 'blue' : 'green',
-                    borderRadius: '50%',
-                    transform: `translate3d(${t.displayX + 200}px, ${-t.displayY + 200}px, 0) translate(-50%, -50%)`,
-                    zIndex: 10,
-                    transition: 'transform 1s linear',
-                    willChange: 'transform',
-                  }"
-                >
-                  <span
-                    style="
-                      color: #fff;
-                      font-size: 12px;
-                      font-weight: 600;
-                      position: absolute;
-                      left: 50%;
-                      top: 50%;
-                      transform: translate(-50%, -50%);
-                      pointer-events: none;
-                    "
-                  >
-                    {{ t.id + 1 }}
-                  </span>
-                </div>
-              </template>
-            </template>
+            <RadarView
+              :angle="detailState.northAngle"
+              :coordinates="[
+                detailState.xxStart,
+                detailState.xxEnd,
+                detailState.yyStart,
+                detailState.yyEnd,
+              ]"
+              :furnitureItems="furnitureItems"
+              :targets="Object.values(targets)"
+            ></RadarView>
           </div>
 
           <div
@@ -217,8 +118,8 @@
           <info-item label="安装位置">
             <template v-if="detailState.clientId">
               {{
-                deviceInstallPositionNameMap[
-                  detailState.installPosition as keyof typeof deviceInstallPositionNameMap
+                installPositionNameMap[
+                  detailState.installPosition as keyof typeof installPositionNameMap
                 ]
               }}
             </template>
@@ -344,10 +245,10 @@ import { useRoute } from 'vue-router'
 import { message } from 'ant-design-vue'
 import * as roomApi from '@/api/room'
 import type { Furniture } from '@/api/room/types'
-import mqtt, { MqttClient } from 'mqtt'
+import mqtt, { type MqttClient } from 'mqtt'
 import * as deviceApi from '@/api/device'
 import type { DeviceDetailData } from '@/api/device/types'
-import { deviceOnlineStateMap, deviceInstallPositionNameMap } from '@/const/device'
+import { deviceOnlineStateMap } from '@/const/device'
 import deviceConfigDrawer from './components/deviceConfig/index.vue'
 import deviceStatsDrawer from './components/deviceStatsDrawer/index.vue'
 import BreathLineChart from './components/breathLineChart/index.vue'
@@ -360,6 +261,7 @@ import * as alarmApi from '@/api/alarm'
 import { Empty } from 'ant-design-vue'
 const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE
 import { getOriginPosition } from '@/utils'
+import { useDict } from '@/hooks/useDict'
 
 defineOptions({
   name: 'DeviceDetail',
@@ -463,6 +365,8 @@ const detailState = ref<DeviceDetailData>({
   tenantName: '',
   tenantId: '',
   fallingConfirm: null,
+  age: null,
+  guardianshipType: null,
 })
 
 const spinning = ref(false)
@@ -514,6 +418,10 @@ const fetchDeviceDetail = async () => {
 }
 fetchDeviceDetail()
 
+const { fetchDict: fetchDictInstallPosition, dictNameMap: installPositionNameMap } =
+  useDict('install_position')
+fetchDictInstallPosition()
+
 const saveConfigSuccess = () => {
   setTimeout(() => {
     fetchDeviceDetail()
@@ -630,7 +538,17 @@ onMounted(() => {
           const [x, y, z, id] = item
           currentIds.add(id)
           if (!(id in targets)) {
-            targets[id] = { x, y, z, id, displayX: x, displayY: y, lastX: x, lastY: y }
+            // targets[id] = { x, y, z, id, displayX: x, displayY: y, lastX: x, lastY: y }
+            targets[id] = {
+              x,
+              y,
+              z,
+              id,
+              displayX: x - Number(detailState.value.xxStart),
+              displayY: y - Number(detailState.value.yyEnd),
+              lastX: x,
+              lastY: y,
+            }
           }
           // 去抖动
           const dx = x - targets[id].lastX
@@ -641,8 +559,10 @@ onMounted(() => {
             targets[id].z = z
             targets[id].lastX = x
             targets[id].lastY = y
-            targets[id].displayX = x
-            targets[id].displayY = y
+            // targets[id].displayX = x
+            // targets[id].displayY = y
+            targets[id].displayX = x - Number(detailState.value.xxStart)
+            targets[id].displayY = y - Number(detailState.value.yyEnd)
             // console.log(`🔄 更新目标点: id=${id}, x=${x}, y=${y}`, targets[id])
           } else {
             // 距离太小,忽略本次更新
@@ -830,10 +750,6 @@ const swtichAlarmItem = async (id: number, swtich: boolean, item: AlarmPlanItem)
   gap: 16px;
 }
 
-.info.pointCard {
-  min-width: 800px;
-}
-
 .pointCloudMap {
   width: 770px;
   height: 100%;
@@ -849,26 +765,6 @@ const swtichAlarmItem = async (id: number, swtich: boolean, item: AlarmPlanItem)
   flex-direction: row;
 }
 
-.radarArea {
-  position: relative;
-  background-image:
-    linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px),
-    linear-gradient(to right, rgba(0, 0, 0, 0.1) 1px, transparent 1px);
-  background-size: 20px 20px;
-  border: 1px solid rgba(0, 0, 0, 0.8);
-  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
-  overflow: hidden;
-  flex-shrink: 0;
-
-  .furniture-item {
-    position: absolute;
-    user-select: none;
-    cursor: move;
-    width: 30px;
-    height: 30px;
-  }
-}
-
 .extraIcon {
   font-size: 16px;
   font-weight: 600;
@@ -886,9 +782,6 @@ const swtichAlarmItem = async (id: number, swtich: boolean, item: AlarmPlanItem)
     min-height: auto;
     margin: auto;
   }
-  .breathLine {
-    margin-left: 0;
-  }
 
   .pointTitle {
     font-size: 16px;

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

@@ -34,8 +34,8 @@
           :rules="[{ required: true, message: '请选择设备类型' }]"
         >
           <a-select v-model:value="formState.deviceType" placeholder="请选择设备类型">
-            <a-select-option value="1">LNA</a-select-option>
-            <a-select-option value="2">LNB</a-select-option>
+            <a-select-option value="LNA">LNA</a-select-option>
+            <a-select-option value="LNB">LNB</a-select-option>
           </a-select>
         </a-form-item>
         <a-form-item
@@ -51,6 +51,7 @@
             v-model:value="formState.tenantId"
             placeholder="请选择归属租户"
             :options="props.options"
+            allow-clear
           >
           </a-select>
         </a-form-item>

+ 19 - 0
vite.config.ts

@@ -67,5 +67,24 @@ export default defineConfig(({ mode }) => {
       },
       cors: true,
     },
+    build: {
+      chunkSizeWarningLimit: 1000,
+
+      rollupOptions: {
+        output: {
+          manualChunks(id) {
+            if (id.includes('node_modules')) {
+              if (id.includes('three')) return 'threejs'
+              if (id.includes('echarts')) return 'echarts'
+              if (id.includes('ant-design-vue')) return 'antdv'
+              if (id.includes('tiny-pinyin')) return 'pinyin'
+              if (id.includes('lodash-es')) return 'lodash'
+              if (id.includes('vue')) return 'vue'
+              return 'vendor'
+            }
+          },
+        },
+      },
+    },
   }
 })