6 次代碼提交 e76518d1d3 ... d85f4cb830

作者 SHA1 備註 提交日期
  liujia d85f4cb830 feat: 新增智慧大屏演示版; 2 天之前
  liujia 451d545a46 Merge branch 'dev' into feature-1.1.1 3 天之前
  liujia da22fdb684 Merge branch 'dev' into feature-1.1.1 3 天之前
  liujia 471831bf8b Merge branch 'dev' into feature-1.1.1 3 天之前
  liujia bdf17dae24 Merge branch 'feature-1.1.0' into feature-1.1.1 4 天之前
  liujia 08e9810e73 feat: 智慧大屏展示版; 4 天之前

+ 5 - 0
src/layout/index.vue

@@ -19,6 +19,7 @@
     <a-layout class="layout-content">
       <a-layout-header>
         <slot name="header">
+          <div class="smartScreen" @click="openSmartScreenDemo">大屏演示版</div>
           <base-weather class="weather" mode="text"></base-weather>
           <div class="smartScreen" @click="openSmartScreen">智慧大屏</div>
 
@@ -342,6 +343,10 @@ const backHandler = async () => {
 const openSmartScreen = () => {
   window.open('/dashboard', '_blank')
 }
+
+const openSmartScreenDemo = () => {
+  window.open('/dashboard-demo', '_blank')
+}
 </script>
 
 <style scoped lang="less">

+ 6 - 0
src/router/index.ts

@@ -33,6 +33,12 @@ const router = createRouter({
       component: () => import('@/views/dashboard/index.vue'),
       meta: { title: '大屏', isFullScreen: true, keepAlive: false, hidden: true },
     },
+    {
+      path: '/dashboard-demo',
+      name: 'dashboardDemo',
+      component: () => import('@/views/dashboard/demo.vue'),
+      meta: { title: '大屏演示', isFullScreen: true, keepAlive: false, hidden: true },
+    },
   ],
 })
 router.beforeEach(authGuard)

+ 5 - 2
src/views/dashboard/components/GuardObjectAgeCard/index.vue

@@ -1,6 +1,6 @@
 <template>
   <TechCard>
-    <div class="card-title">监护对象年龄分布</div>
+    <div class="card-title">{{ props.title }}</div>
     <BaseChart :option="chartOption" :height="220" />
   </TechCard>
 </template>
@@ -18,9 +18,12 @@ interface AgeItem {
 
 interface Props {
   ageList: AgeItem[]
+  title?: string
 }
 
-const props = defineProps<Props>()
+const props = withDefaults(defineProps<Props>(), {
+  title: '监护对象年龄分布',
+})
 
 const chartOption = computed(() => {
   if (!props.ageList || props.ageList.length === 0) {

+ 5 - 3
src/views/dashboard/components/GuardObjectTypeCard/index.vue

@@ -1,6 +1,6 @@
 <template>
   <TechCard>
-    <div class="card-title">监护对象类型</div>
+    <div class="card-title">{{ props.title }}</div>
     <BaseChart :option="chartOption" :height="180" />
   </TechCard>
 </template>
@@ -19,10 +19,12 @@ interface GuardItem {
 
 interface Props {
   guardList: GuardItem[]
+  title?: string
 }
 
-const props = defineProps<Props>()
-
+const props = withDefaults(defineProps<Props>(), {
+  title: '监护对象类型',
+})
 // 提取分类名称和数量
 const categories = computed(() => props.guardList.map((item) => item.name))
 const counts = computed(() => props.guardList.map((item) => Number(item.count)))

+ 3 - 1
src/views/dashboard/components/MonitorPeopleCountCard/index.vue

@@ -1,7 +1,7 @@
 <template>
   <TechCard class="people-detected-card">
     <div class="card-header">
-      <div class="title">检测到人数</div>
+      <div class="title">{{ props.title }}</div>
     </div>
 
     <div class="matrix-wrapper">
@@ -42,11 +42,13 @@ defineOptions({ name: 'MonitorPeopleCountCard' })
 type Props = {
   detectedCount: number
   limit?: number // 限制显示的人数
+  title?: string
 }
 
 const props = withDefaults(defineProps<Props>(), {
   detectedCount: 0,
   limit: 28,
+  title: '检测到人数',
 })
 </script>
 

+ 1161 - 0
src/views/dashboard/demo.vue

@@ -0,0 +1,1161 @@
+<template>
+  <div class="dashboard">
+    <div class="dashboard-header">
+      <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 style="cursor: pointer; font-weight: 600" @click="goToHome">返回首页</div>
+      </div>
+      <div class="data-flow header-flow"></div>
+    </div>
+    <div class="dashboard-content">
+      <div class="block custom-scroll">
+        <div class="data-row">
+          <GuardObjectAgeCard :ageList="todayData.ageList" title="入住老人年龄分布" />
+          <GuardObjectTypeCard :guardList="todayData.guardList" title="检测对象分级" />
+          <MonitorPeopleCountCard :detectedCount="todayData.detectedCount" title="今日检测到人数" />
+          <ElderActivityCard :activity-rate="todayData.activeRate" />
+        </div>
+
+        <div class="editor-note">
+          <TechCard>
+            <div class="editor-note-hd">长者基础事件备忘录</div>
+            <div class="editor-note-bd">
+              <div>305房间,昨日发生滞留</div>
+              <div>802老人,昨日发生摔倒</div>
+              <div>601房间,昨日如厕异常</div>
+              <div>708房间,昨日发生摔倒</div>
+              <div>503房间,昨日发生滞留</div>
+              <div>915房间,昨日如厕异常</div>
+              <!-- 重复一遍内容以实现无缝滚动 -->
+              <div>305房间,昨日发生滞留</div>
+              <div>802老人,昨日发生摔倒</div>
+              <div>601房间,昨日如厕异常</div>
+              <div>708房间,昨日发生摔倒</div>
+              <div>503房间,昨日发生滞留</div>
+              <div>915房间,昨日如厕异常</div>
+            </div>
+          </TechCard>
+        </div>
+
+        <!-- <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 :data="todayData.installPositionList"></DeviceLocationCard> -->
+      </div>
+      <div class="block block-center custom-scroll">
+        <div class="map-container">
+          <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 custom-scroll" style="padding: 10px">
+        <div class="data-row">
+          <DeviceOnlineRateCard
+            :online-count="todayData.onlineCount"
+            :device-count="todayData.deviceCount"
+          />
+          <DeviceLocationCard :data="todayData.installPositionList" />
+        </div>
+        <AlertFallCompareCard
+          :fall-count="todayData.fallingCount"
+          :alert-count="todayData.alarmCount"
+          style="margin-bottom: 10px"
+        />
+
+        <!-- <div class="data-row">
+          <GuardObjectAgeCard :ageList="todayData.ageList"></GuardObjectAgeCard>
+          <ElderActivityCard :activity-rate="todayData.activeRate"></ElderActivityCard>
+        </div> -->
+
+        <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">
+      <!-- <Copyright
+        company="合肥雷能信息技术有限公司"
+        icp="皖ICP备2024060056号-3"
+        icp-link="https://beian.miit.gov.cn"
+        icp-text="皖ICP备2024060056号-3"
+        font-color="#4774a7"
+      /> -->
+      <div>305房间19:00 发生跌倒事件,请及时处理;</div>
+      <div>802老人17:00 发生异常消失,已处理;</div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+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 MonitorPeopleCountCard from './components/MonitorPeopleCountCard/index.vue'
+import DeviceLocationCard from './components/DeviceLocationCard/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'
+import TechCard from './components/TechCard/index.vue'
+import { set } from 'lodash-es'
+
+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()
+  const year = now.getFullYear()
+  const month = String(now.getMonth() + 1).padStart(2, '0')
+  const day = String(now.getDate()).padStart(2, '0')
+  const hours = String(now.getHours()).padStart(2, '0')
+  const minutes = String(now.getMinutes()).padStart(2, '0')
+  const seconds = String(now.getSeconds()).padStart(2, '0')
+  currentTime.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
+}
+
+onMounted(() => {
+  formatTime()
+  const timeInterval = setInterval(formatTime, 1000)
+
+  onUnmounted(() => {
+    clearInterval(timeInterval)
+  })
+})
+
+const todayData = ref<TodayData>({
+  deviceCount: 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
+
+      todayData.value.systemGuardDay = Math.round(Math.random() * 100) // 守护天数
+
+      setInterval(() => {
+        console.log('🚀🚀🚀 mock 数据更新了')
+        localStorage.setItem('todayData', JSON.stringify(todayData.value))
+        mockData()
+      }, 2000)
+
+      setInterval(() => {
+        todayData.value.systemGuardDay = Math.round(Math.random() * 100) // 守护天数
+      }, 5000)
+    }
+  },
+  { immediate: true }
+)
+
+// mock 数据
+const mockData = () => {
+  // todayData.value.systemGuardDay = Math.round(Math.random() * 100) // 守护天数
+  // 年龄分布
+  todayData.value.ageList = [
+    { ageRange: '60-69岁', count: Math.floor(Math.random() * 50) + 10 },
+    { ageRange: '70-79岁', count: Math.floor(Math.random() * 50) + 10 },
+    { ageRange: '80-89岁', count: Math.floor(Math.random() * 50) + 10 },
+    { ageRange: '90-99岁', count: Math.floor(Math.random() * 50) + 10 },
+    { ageRange: '100岁以上', count: Math.floor(Math.random() * 50) + 10 },
+  ]
+  // 检测对象分级
+  todayData.value.guardList = [
+    { guardType: 'parent', name: '重点对象', count: Math.floor(Math.random() * 50) + 10 },
+    { guardType: 'child', name: '一般对象', count: Math.floor(Math.random() * 50) + 10 },
+    { guardType: 'guardian', name: '普通对象', count: Math.floor(Math.random() * 50) + 10 },
+  ]
+  // 检测人数
+  todayData.value.detectedCount = Math.floor(Math.random() * 500) + 100
+  // 长者活跃度
+  todayData.value.activeRate = Math.round(Math.random() * 100)
+  // 设备在线率
+  todayData.value.onlineCount = Math.round(Math.random() * 2500)
+  todayData.value.deviceCount = 2568
+  // 设备安装位置
+  todayData.value.installPositionList = [
+    { installPosition: 'Bedroom', name: '卧室', count: Math.floor(Math.random() * 50) + 10 },
+    { installPosition: 'LivingRoom', name: '客厅', count: Math.floor(Math.random() * 50) + 10 },
+    { installPosition: 'Restaurant', name: '餐厅', count: Math.floor(Math.random() * 50) + 10 },
+    { installPosition: 'Toilet', name: '卫生间', count: Math.floor(Math.random() * 50) + 10 },
+  ]
+  // 跌倒次数
+  todayData.value.fallingCount = Math.floor(Math.random() * 500) + 100
+  // 报警次数
+  todayData.value.alarmCount = Math.floor(Math.random() * 500) + 100
+
+  const generateRecent7DaysData = () => {
+    const today = new Date()
+    const dayStatInfo = []
+
+    for (let i = 6; i >= 0; i--) {
+      const date = new Date(today)
+      date.setDate(today.getDate() - i)
+      const formattedDate = date.toISOString().split('T')[0]
+      dayStatInfo.push({
+        date: formattedDate,
+        fallingCount: Math.floor(Math.random() * 50) + 10,
+        alarmCount: Math.floor(Math.random() * 50) + 10,
+      })
+    }
+
+    return dayStatInfo
+  }
+
+  const generateRecent180DaysMonthlyData = () => {
+    const today = new Date()
+    const monthStatInfo = []
+
+    for (let i = 5; i >= 0; i--) {
+      const date = new Date(today)
+      date.setMonth(today.getMonth() - i)
+      const formattedMonth = date.toISOString().slice(0, 7)
+      monthStatInfo.push({
+        month: formattedMonth,
+        fallingCount: Math.floor(Math.random() * 200) + 50,
+        alarmCount: Math.floor(Math.random() * 200) + 50,
+      })
+    }
+
+    return monthStatInfo
+  }
+
+  fallHistoryData.value = {
+    dayStatInfo: generateRecent7DaysData().map((d) => ({
+      date: d.date,
+      fallingCount: d.fallingCount,
+    })),
+    monthStatInfo: generateRecent180DaysMonthlyData().map((m) => ({
+      month: m.month,
+      fallingCount: m.fallingCount,
+    })),
+  }
+
+  alarmHistoryData.value = {
+    dayStatInfo: generateRecent7DaysData().map((d) => ({
+      date: d.date,
+      alarmCount: d.alarmCount,
+    })),
+    monthStatInfo: generateRecent180DaysMonthlyData().map((m) => ({
+      month: m.month,
+      alarmCount: m.alarmCount,
+    })),
+  }
+}
+mockData()
+
+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
+  }
+}
+
+const goToHome = () => {
+  window.open('/', '_blank')
+}
+</script>
+
+<style scoped lang="less">
+@bg-color: #22284a;
+@text-color: #e0e0e0;
+@panel-bg: #0b173f;
+@border-color: #2a3b5a;
+@primary-color: #4dc9e6;
+@secondary-color: #6de4ff;
+@accent-color: #2572ed;
+@success-color: #2ecc71;
+@warning-color: #f39c12;
+@danger-color: #e74c3c;
+@gradient-start: #181c41;
+@gradient-end: #22284a;
+@glow-color: rgba(77, 201, 230, 0.2);
+@data-flow-color: rgba(77, 201, 230, 0.7);
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+  font-family: 'Microsoft YaHei', Arial, sans-serif;
+}
+
+.dashboard {
+  background-color: @bg-color;
+  color: @text-color;
+  height: 100vh;
+  width: 100vw;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+
+  &-header {
+    height: 70px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 0 20px;
+    background-color: @panel-bg;
+    border: 1px solid @border-color;
+    border-radius: 8px;
+    box-shadow: 0 0 15px rgba(0, 180, 255, 0.2);
+    position: relative;
+    flex-wrap: wrap;
+
+    .community-name {
+      font-size: 24px;
+      background: linear-gradient(90deg, @primary-color, @secondary-color);
+      -webkit-background-clip: text;
+      background-clip: text;
+      -webkit-text-fill-color: transparent;
+      text-shadow: 0 0 10px rgba(109, 228, 255, 0.5);
+      letter-spacing: 2px;
+      flex-shrink: 0;
+      display: flex;
+      align-items: center;
+      .fixedName {
+        margin-right: 10px;
+      }
+
+      .tenantName {
+        max-width: 300px;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+    }
+
+    .running-days {
+      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: 20px;
+      color: @primary-color;
+      flex-shrink: 0;
+    }
+
+    .data-flow {
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      width: 100%;
+      height: 3px;
+      background: linear-gradient(90deg, transparent, rgba(77, 201, 230, 0.7), transparent);
+      animation: dataFlow 2s linear infinite;
+    }
+
+    @keyframes dataFlow {
+      0% {
+        transform: translateX(-100%);
+      }
+      100% {
+        transform: translateX(100%);
+      }
+    }
+  }
+
+  &-content {
+    // padding: 24px;
+    padding: 10px 20px 5px;
+    flex: 1;
+    border-radius: 8px;
+    display: flex;
+    gap: 20px;
+    min-height: 0;
+    overflow-y: auto;
+
+    .block {
+      flex-grow: 1;
+      flex-shrink: 1;
+      flex-basis: 400px;
+      display: flex;
+      flex-direction: column;
+      position: relative;
+      overflow-y: auto;
+    }
+
+    .block-center {
+      flex-grow: 1;
+      flex-shrink: 1;
+      flex-basis: 300px;
+      display: flex;
+      position: relative;
+
+      .map-container {
+        position: relative;
+        display: flex;
+        border-radius: 6px;
+        height: 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 {
+        object-fit: contain;
+        display: block;
+        border-radius: 6px;
+        pointer-events: none;
+      }
+
+      .map-label {
+        position: absolute;
+        background: rgba(32, 40, 65, 0.9);
+        border: 1px solid @border-color;
+        border-radius: 4px;
+        padding: 4px 8px;
+        font-size: 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%);
+        cursor: pointer;
+      }
+
+      .building-1 {
+        top: 200px;
+        left: 200px;
+        animation: pulse 2s infinite;
+      }
+
+      .building-2 {
+        top: 250px;
+        left: 330px;
+        animation: pulse 2s infinite;
+      }
+
+      .building-3 {
+        top: 150px;
+        left: 450px;
+        animation: pulse 2s infinite;
+      }
+
+      .building-4 {
+        top: 350px;
+        left: 380px;
+        animation: pulse-red 2s infinite;
+      }
+
+      @keyframes pulse {
+        0% {
+          box-shadow: 0 0 0 0 rgba(77, 201, 230, 0.4);
+        }
+        70% {
+          box-shadow: 0 0 0 12px rgba(77, 201, 230, 0);
+        }
+        100% {
+          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 1fr;
+      gap: 12px;
+      margin-bottom: 12px;
+    }
+
+    .data-row {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 12px;
+      margin-bottom: 12px;
+    }
+
+    // 长者基础事件备忘录
+    .editor-note {
+      color: @text-color;
+      margin-bottom: 10px;
+
+      &-hd {
+        font-weight: bold;
+        margin-bottom: 8px;
+        color: #6de4ff;
+      }
+
+      &-bd {
+        height: 130px;
+        overflow: hidden;
+        position: relative;
+        line-height: 1.5;
+      }
+
+      &-bd::before {
+        content: '';
+        position: absolute;
+        top: 0;
+        left: 0;
+        right: 0;
+        bottom: 0;
+        pointer-events: none;
+      }
+
+      &-bd > div {
+        animation: scrollUp 12s linear infinite;
+      }
+
+      @keyframes scrollUp {
+        0% {
+          transform: translateY(100%);
+        }
+        100% {
+          transform: translateY(-100%);
+        }
+      }
+    }
+
+    .data-line {
+      display: grid;
+      //  grid-template-columns: 1fr;
+      grid-template-columns: 1fr 1fr;
+      gap: 12px;
+    }
+  }
+
+  &-footer {
+    // height: 20px;
+    // color: @text-color;
+    // color: #6de4ff;
+    // text-align: center;
+    // line-height: 30px;
+    // font-size: 12px;
+    // margin-bottom: 10px;
+
+    display: flex;
+    justify-content: space-between;
+    div {
+      flex-grow: 1;
+      text-align: center;
+      padding: 10px;
+      border: 3px solid @border-color;
+      margin: 10px 24px;
+    }
+  }
+
+  @media (max-width: 1600px) {
+    .dashboard-header .community-name {
+      font-size: 20px;
+    }
+
+    .dashboard-header .running-days {
+      font-size: 18px;
+    }
+
+    .dashboard-header .time-info {
+      font-size: 16px;
+    }
+  }
+
+  @media (max-width: 1200px) {
+    .dashboard-content {
+      flex-direction: column;
+      gap: 15px;
+    }
+
+    .dashboard-content .block {
+      min-height: 400px;
+    }
+
+    .dashboard-content .data-grid,
+    .dashboard-content .data-row {
+      grid-template-columns: 1fr;
+    }
+  }
+
+  @media (max-width: 768px) {
+    .dashboard-header {
+      height: auto;
+      padding: 15px;
+      flex-direction: column;
+      gap: 10px;
+      text-align: center;
+    }
+
+    .dashboard-header .community-name,
+    .dashboard-header .running-days,
+    .dashboard-header .time-info {
+      font-size: 16px;
+    }
+
+    .dashboard-content {
+      padding: 15px;
+      min-height: calc(100vh - 150px - 50px);
+    }
+
+    .dashboard-content .block {
+      min-height: 300px;
+    }
+
+    .map-label {
+      font-size: 12px !important;
+      padding: 2px 6px !important;
+    }
+  }
+}
+
+.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>