Selaa lähdekoodia

feat: 智慧大屏管理员支持切换租户;智慧大屏守护天数滚动效果;

liujia 1 kuukausi sitten
vanhempi
commit
56a9e8548b

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

@@ -40,4 +40,5 @@ export interface LoginResponseData {
   account: string // 账户
   tenantId: string // 租户ID
   tenantName: string // 租户名称
+  userType: string // 用户类型  超管 'admin' | 'manager' 其他租户管理员 'user_admin' ...
 }

+ 11 - 20
src/hooks/useDashboardPolling.ts

@@ -1,4 +1,4 @@
-import { onMounted, onUnmounted, ref } from 'vue'
+import { ref } from 'vue'
 import * as statsApi from '@/api/stats'
 import type {
   StatsHomeScreenQueryData,
@@ -11,7 +11,6 @@ export function useDashboardPolling(options: { tenantId: string }) {
   const fallHistoryData = ref<StatsHomeScreenFallHistory | null>(null)
   const alarmHistoryData = ref<StatsHomeScreenAlarmHistory | null>(null)
 
-  // queryType 分开管理
   const fallQueryType = ref<'day' | 'month'>('day')
   const alarmQueryType = ref<'day' | 'month'>('day')
 
@@ -31,9 +30,8 @@ export function useDashboardPolling(options: { tenantId: string }) {
       })
       todayScreenData.value = res.data
       retryCountRealtime = 0
-    } catch (err) {
+    } catch {
       retryCountRealtime++
-      console.warn(`实时数据获取失败(第 ${retryCountRealtime} 次)`, err)
       if (retryCountRealtime < MAX_RETRY) {
         setTimeout(getTodayData, 1000)
       }
@@ -56,48 +54,39 @@ export function useDashboardPolling(options: { tenantId: string }) {
       fallHistoryData.value = fallRes.data
       alarmHistoryData.value = alarmRes.data
       retryCountBusiness = 0
-    } catch (err) {
+    } catch {
       retryCountBusiness++
-      console.warn(`历史数据获取失败(第 ${retryCountBusiness} 次)`, err)
       if (retryCountBusiness < MAX_RETRY) {
         setTimeout(getHistoryData, 2000)
       }
     }
   }
 
-  const startPolling = () => {
+  const start = () => {
     getTodayData()
     getHistoryData()
     realtimeTimer = setInterval(getTodayData, 5000)
     businessTimer = setInterval(getHistoryData, 30000)
+    document.addEventListener('visibilitychange', handleVisibilityChange)
   }
 
-  const stopPolling = () => {
+  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) {
-      startPolling()
+      start()
     } else {
-      stopPolling()
+      stop()
     }
   }
 
-  onMounted(() => {
-    startPolling()
-    document.addEventListener('visibilitychange', handleVisibilityChange)
-  })
-
-  onUnmounted(() => {
-    stopPolling()
-    document.removeEventListener('visibilitychange', handleVisibilityChange)
-  })
-
   const updateFallQueryType = async (type: 'day' | 'month') => {
     fallQueryType.value = type
     const res = await statsApi.statsHomeScreenFallHistory({
@@ -122,5 +111,7 @@ export function useDashboardPolling(options: { tenantId: string }) {
     alarmHistoryData,
     updateFallQueryType,
     updateAlarmQueryType,
+    start,
+    stop,
   }
 }

+ 3 - 0
src/stores/user.ts

@@ -24,6 +24,7 @@ type UserInfo = {
   account: string // 账户
   tenantId: string // 租户ID
   tenantName: string // 租户名称
+  userType: string // 用户类型  超管 'admin' | 'manager' 其他租户管理员 'user_admin' ...
 }
 
 export const useUserStore = defineStore(
@@ -48,6 +49,7 @@ export const useUserStore = defineStore(
       account: '',
       tenantId: '',
       tenantName: '',
+      userType: '',
     })
 
     // 登录
@@ -100,6 +102,7 @@ export const useUserStore = defineStore(
         account: '',
         tenantId: '',
         tenantName: '',
+        userType: '',
       }
       const redirectPath = router.currentRoute.value.fullPath || ''
       console.log('✅ userStore Logout', redirectPath)

+ 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>

+ 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>

+ 249 - 45
src/views/dashboard/index.vue

@@ -1,9 +1,27 @@
 <template>
   <div class="dashboard">
     <div class="dashboard-header">
-      <div class="community-name">{{ tenantName }}</div>
-      <div class="running-days">已安全守护 {{ todayData.systemGuardDay }} 天</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">
@@ -108,7 +126,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, onUnmounted, watch } 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'
@@ -117,15 +135,30 @@ 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 = (userStore.userInfo.tenantName ?? '雷能技术') + ' 智慧大屏'
+
+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',
@@ -167,53 +200,125 @@ const todayData = ref<TodayData>({
 
 useResponsiveLayout()
 
-const {
-  todayScreenData,
-  fallHistoryData,
-  alarmHistoryData,
-  updateFallQueryType,
-  updateAlarmQueryType,
-} = useDashboardPolling({ tenantId: userStore.userInfo.tenantId || '' })
+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')
-fetchDictGuardianship()
 
 const { fetchDict: fetchDictInstallPosition, dictNameMap: installPositionNameMap } =
   useDict('install_position')
-fetchDictInstallPosition().then(() => {
-  console.log('🚀🚀🚀 installPositionNameMap.value', installPositionNameMap.value)
-})
+
+Promise.all([fetchDictGuardianship(), fetchDictInstallPosition()])
 
 watch(
   () => todayScreenData.value,
   (val) => {
-    console.log('todayScreenData更新了', val)
-    todayData.value = val || todayData.value
-    todayData.value.ageList = val?.ageList.filter((item) => item.count > 0) ?? []
+    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.map((item) => ({
-        ...item,
-        name:
-          installPositionNameMap.value[
-            item.installPosition as keyof typeof installPositionNameMap.value
-          ] || '未知',
-        names: installPositionNameMap.value,
-      })) ?? []
+      (val?.installPositionList &&
+        val?.installPositionList.map((item) => ({
+          ...item,
+          name:
+            installPositionNameMap.value[
+              item.installPosition as keyof typeof installPositionNameMap.value
+            ] || '未知',
+          names: installPositionNameMap.value,
+        }))) ??
+      []
     todayData.value.guardList =
-      todayData.value.guardList.map((item) => ({
-        ...item,
-        name:
-          guardTypeNameMap.value[item.guardType as keyof typeof guardTypeNameMap.value] || '未知',
-        names: guardTypeNameMap.value,
-      })) ?? []
+      (val?.guardList &&
+        val?.guardList.map((item) => ({
+          ...item,
+          name:
+            guardTypeNameMap.value[item.guardType as keyof typeof guardTypeNameMap.value] || '未知',
+          names: guardTypeNameMap.value,
+        }))) ??
+      []
   },
-  { immediate: true }
+  { immediate: true, deep: true }
 )
 
-type RawDayItem = { date: string; fallingCount?: number; alarmCount?: number }
-type RawMonthItem = { month: string; fallingCount?: number; alarmCount?: number }
-
 type StatInfo = { lable: string; count: number }
 type HistoryData = { monthStatInfo: StatInfo[]; dayStatInfo: StatInfo[] }
 
@@ -260,7 +365,7 @@ watch(
       'fallingCount'
     )
   },
-  { immediate: true }
+  { immediate: true, deep: true }
 )
 
 // 监听告警数据
@@ -279,7 +384,7 @@ watch(
       'alarmCount'
     )
   },
-  { immediate: true }
+  { immediate: true, deep: true }
 )
 
 const alarmMode = ref<'day' | 'month'>('day')
@@ -292,7 +397,7 @@ const changeAlarmMode = async (mode: 'day' | 'month') => {
   if (alarmMode.value !== mode) {
     alarmMode.value = mode
     alarmLoading.value = true
-    await updateAlarmQueryType(mode)
+    await updateAlarmQueryType?.(mode)
     alarmLoading.value = false
   }
 }
@@ -301,7 +406,7 @@ const changeFallMode = async (mode: 'day' | 'month') => {
   if (fallMode.value !== mode) {
     fallMode.value = mode
     fallLoading.value = true
-    await updateFallQueryType(mode)
+    await updateFallQueryType?.(mode)
     fallLoading.value = false
   }
 }
@@ -388,7 +493,6 @@ const handleResize = () => {
     border-radius: 8px;
     box-shadow: 0 0 15px rgba(0, 180, 255, 0.2);
     position: relative;
-    overflow: hidden;
     flex-wrap: wrap;
 
     .community-name {
@@ -400,13 +504,55 @@ const handleResize = () => {
       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 {
@@ -685,6 +831,64 @@ const handleResize = () => {
     }
   }
 }
+
+: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">