Prechádzať zdrojové kódy

feat: 智慧大屏展示版;

liujia 4 dní pred
rodič
commit
08e9810e73
2 zmenil súbory, kde vykonal 944 pridanie a 1 odobranie
  1. 2 1
      src/router/index.ts
  2. 942 0
      src/views/dashboard/demo.vue

+ 2 - 1
src/router/index.ts

@@ -30,7 +30,8 @@ const router = createRouter({
     {
       path: '/dashboard',
       name: 'dashboard',
-      component: () => import('@/views/dashboard/index.vue'),
+      // component: () => import('@/views/dashboard/index.vue'),
+      component: () => import('@/views/dashboard/demo.vue'),
       meta: { title: '大屏', isFullScreen: true, keepAlive: false, hidden: true },
     },
   ],

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

@@ -0,0 +1,942 @@
+<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>
+      <div class="data-flow header-flow"></div>
+    </div>
+    <div class="dashboard-content">
+      <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 :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">
+          <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>
+  </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'
+
+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
+    }
+  },
+  { 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">
+@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;
+      }
+    }
+
+    .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;
+    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;
+    }
+
+    .data-line {
+      display: grid;
+      grid-template-columns: 1fr;
+      gap: 12px;
+    }
+  }
+
+  &-footer {
+    height: 20px;
+    color: @text-color;
+    color: #6de4ff;
+    text-align: center;
+    line-height: 30px;
+    font-size: 12px;
+    margin-bottom: 10px;
+  }
+
+  @media (max-width: 1600px) {
+    .dashboard-header .community-name {
+      font-size: 20px;
+    }
+
+    .dashboard-header .running-days {
+      font-size: 18px;
+    }
+
+    .dashboard-header .time-info {
+      font-size: 16px;
+    }
+  }
+
+  @media (max-width: 1200px) {
+    .dashboard-content {
+      flex-direction: column;
+      gap: 15px;
+    }
+
+    .dashboard-content .block {
+      min-height: 400px;
+    }
+
+    .dashboard-content .data-grid,
+    .dashboard-content .data-row {
+      grid-template-columns: 1fr;
+    }
+  }
+
+  @media (max-width: 768px) {
+    .dashboard-header {
+      height: auto;
+      padding: 15px;
+      flex-direction: column;
+      gap: 10px;
+      text-align: center;
+    }
+
+    .dashboard-header .community-name,
+    .dashboard-header .running-days,
+    .dashboard-header .time-info {
+      font-size: 16px;
+    }
+
+    .dashboard-content {
+      padding: 15px;
+      min-height: calc(100vh - 150px - 50px);
+    }
+
+    .dashboard-content .block {
+      min-height: 300px;
+    }
+
+    .map-label {
+      font-size: 12px !important;
+      padding: 2px 6px !important;
+    }
+  }
+}
+
+.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>