五、数据展示组件
5.1 数字翻牌器组件
数字翻牌器是大屏中最常见的数据展示组件之一,用于展示关键指标(如 GMV、用户数、订单量)。它的核心特点是:数据变化时,数字以“翻牌”的动画效果滚动变化,给用户强烈的数据变化感知。
设计思路:
将数字的每一位拆分成独立的位,分别进行动画
支持千分位分隔符、小数位数、单位等配置
支持进入可视区域才开始动画(懒启动)
<template>
<div class="digital-flop" :class="{ 'is-visible': isVisible }" ref="flopRef">
<div class="flop-title" v-if="title">{
{ title }}</div>
<div class="flop-value-wrapper">
<div class="flop-value">
<!-- 前缀(如货币符号) -->
<span v-if="prefix" class="flop-prefix">{
{ prefix }}</span>
<!-- 整数部分(按位拆分) -->
<span v-for="(digit, idx) in integerDigits" :key="idx" class="digit">
<span class="digit-inner">{
{ digit }}</span>
</span>
<!-- 小数点 -->
<span v-if="decimalPart !== null" class="decimal-point">.</span>
<!-- 小数部分 -->
<span v-for="(digit, idx) in decimalDigits" :key="idx" class="digit digit-decimal">
<span class="digit-inner">{
{ digit }}</span>
</span>
<!-- 单位 -->
<span v-if="unit" class="flop-unit">{
{ unit }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
const props = defineProps({
value: { type: Number, required: true },
title: { type: String, default: '' },
unit: { type: String, default: '' },
prefix: { type: String, default: '' },
decimalPlaces: { type: Number, default: 0 },
thousandSeparator: { type: Boolean, default: true },
animationDuration: { type: Number, default: 1000 },
useIntersectionObserver: { type: Boolean, default: true }
})
const isVisible = ref(!props.useIntersectionObserver)
const flopRef = ref(null)
const currentValue = ref(0)
// 格式化数字(添加千分位、小数位)
const formattedValue = computed(() => {
const rounded = currentValue.value.toFixed(props.decimalPlaces)
const parts = rounded.split('.')
const integerPart = parts[0]
const decimalPart = parts[1] || ''
// 添加千分位分隔符
const integerWithSeparator = props.thousandSeparator
? integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
: integerPart
return { integer: integerWithSeparator, decimal: decimalPart }
})
// 整数部分按位拆分为数组
const integerDigits = computed(() => {
const intStr = formattedValue.value.integer
return intStr.split('')
})
// 小数部分按位拆分
const decimalDigits = computed(() => {
const decStr = formattedValue.value.decimal
return decStr ? decStr.split('') : []
})
// 小数部分是否存在
const decimalPart = computed(() => props.decimalPlaces > 0)
// 数字滚动动画
const animateValue = () => {
const startValue = currentValue.value
const endValue = props.value
const startTime = performance.now()
const updateValue = (currentTime) => {
const elapsed = currentTime - startTime
const progress = Math.min(1, elapsed / props.animationDuration)
// 使用 easeOutCubic 缓动函数,使动画更平滑
const easeProgress = 1 - Math.pow(1 - progress, 3)
currentValue.value = startValue + (endValue - startValue) * easeProgress
if (progress < 1) {
requestAnimationFrame(updateValue)
} else {
currentValue.value = endValue
}
}
requestAnimationFrame(updateValue)
}
// 观察组件是否进入可视区域
let observer = null
if (props.useIntersectionObserver) {
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
isVisible.value = true
observer?.disconnect()
observer = null
}
}, { threshold: 0.1 })
}
onMounted(() => {
if (observer && flopRef.value) {
observer.observe(flopRef.value)
}
// 初始值直接设为目标值(避免动画)
currentValue.value = props.value
})
// 监听 value 变化,触发动效
watch(() => props.value, () => {
if (isVisible.value) {
animateValue()
} else {
currentValue.value = props.value
}
})
</script>
<style scoped>
.digital-flop {
text-align: center;
background: rgba(0, 0, 0, 0.4);
border-radius: 12px;
padding: 24px;
backdrop-filter: blur(10px);
opacity: 0;
transform: translateY(20px);
transition: all 0.5s ease;
}
.digital-flop.is-visible {
opacity: 1;
transform: translateY(0);
}
.flop-title {
font-size: 18px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 16px;
letter-spacing: 2px;
}
.flop-value {
font-size: 56px;
font-weight: bold;
color: #00ffff;
text-shadow: 0 0 10px rgba(0, 255, 255, 0.5);
display: flex;
justify-content: center;
align-items: baseline;
flex-wrap: wrap;
gap: 4px;
}
.digit {
display: inline-block;
min-width: 48px;
text-align: center;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
margin: 0 2px;
padding: 8px 4px;
position: relative;
overflow: hidden;
box-shadow: 0 0 8px rgba(0, 255, 255, 0.3);
}
.digit-inner {
display: inline-block;
animation: flipIn 0.3s ease;
}
@keyframes flipIn {
0% { transform: translateY(-100%); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
.digit-decimal {
min-width: 24px;
}
.decimal-point {
font-size: 48px;
margin: 0 2px;
color: #00ffff;
}
.flop-prefix,
.flop-unit {
font-size: 32px;
margin: 0 8px;
color: rgba(255, 255, 255, 0.7);
}
@media (max-width: 1024px) {
.flop-value { font-size: 40px; }
.digit { min-width: 36px; padding: 4px; }
.decimal-point { font-size: 36px; }
}
</style>
六、实时数据与动态刷新
6.1 实时数据刷新策略
大屏应用通常需要展示实时数据,因此数据刷新是核心功能之一。常见的数据刷新策略有:
在大屏场景下,如果实时性要求高(如金融行情、网络监控),推荐使用 WebSocket;如果实时性要求一般(如每小时更新),使用轮询即可。
6.2 基于 WebSocket 的实时数据实现
// composables/useWebSocket.js
import { ref, onUnmounted } from 'vue'
export function useWebSocket(url, options = {}) {
const {
autoConnect = true,
onMessage = () => {},
onOpen = () => {},
onClose = () => {},
onError = () => {},
reconnectInterval = 3000,
maxReconnectAttempts = 10
} = options
const ws = ref(null)
const isConnected = ref(false)
const reconnectAttempts = ref(0)
let reconnectTimer = null
const connect = () => {
ws.value = new WebSocket(url)
ws.value.onopen = () => {
isConnected.value = true
reconnectAttempts.value = 0
onOpen()
}
ws.value.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
onMessage(data)
} catch (e) {
console.error('WebSocket 数据解析失败:', e)
}
}
ws.value.onclose = () => {
isConnected.value = false
onClose()
// 自动重连
if (reconnectAttempts.value < maxReconnectAttempts) {
reconnectTimer = setTimeout(() => {
reconnectAttempts.value++
connect()
}, reconnectInterval)
}
}
ws.value.onerror = (error) => {
onError(error)
}
}
const send = (data) => {
if (ws.value && isConnected.value) {
ws.value.send(JSON.stringify(data))
}
}
const close = () => {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
}
if (ws.value) {
ws.value.close()
}
}
if (autoConnect) {
connect()
}
onUnmounted(() => {
close()
})
return { isConnected, send, close }
}
在组件中使用:
<script setup>
import { ref } from 'vue'
import { useWebSocket } from '@/composables/useWebSocket'
const realtimeData = ref({})
const handleMessage = (data) => {
// 更新响应式数据
realtimeData.value = data
// 可以在这里触发动画或更新图表
}
const { isConnected } = useWebSocket('ws://localhost:8080/ws', {
onMessage: handleMessage,
onOpen: () => console.log('WebSocket 已连接'),
onClose: () => console.log('WebSocket 已断开')
})
</script>
6.3 数据更新动画
为了让数据变化更加平滑,可以添加过渡动画:
<template>
<div :key="dataVersion" class="data-container fade-in">
<!-- 数据内容 -->
</div>
</template>
<script setup>
import { ref } from 'vue'
const dataVersion = ref(0)
const updateData = (newData) => {
data.value = newData
// 刷新 key,触发重新渲染动画
dataVersion.value++
}
</script>
<style>
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.3s ease;
}
</style>
七、性能优化
7.1 组件懒加载
对于大屏页面中的非首屏组件,可以使用 Vue 的异步组件进行懒加载:
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent(() =>
import('@/components/HeavyChart.vue')
)
7.2 图表实例复用与销毁
// 组件卸载时销毁图表实例,避免内存泄漏
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
})
7.3 数据缓存
对于不常变化的数据(如配置项、字典数据),可以使用缓存减少请求:
const dataCache = new Map()
const fetchData = async (key, fetcher, ttl = 60000) => {
const cached = dataCache.get(key)
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data
}
const data = await fetcher()
dataCache.set(key, { data, timestamp: Date.now() })
return data
}
7.4 虚拟滚动
对于超长列表(如日志、排行榜),使用虚拟滚动只渲染可视区域内的数据:
<template>
<div class="virtual-list-container" ref="container" @scroll="handleScroll">
<div class="virtual-list-phantom" :style="{ height: totalHeight + 'px' }"></div>
<div class="virtual-list-content" :style="{ transform: `translateY(${offsetY}px)` }">
<div v-for="item in visibleData" :key="item.id" class="list-item">
{
{ item.name }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
data: { type: Array, required: true },
itemHeight: { type: Number, default: 50 },
bufferSize: { type: Number, default: 5 }
})
const container = ref(null)
const scrollTop = ref(0)
const totalHeight = computed(() => props.data.length * props.itemHeight)
const startIndex = computed(() => {
const index = Math.floor(scrollTop.value / props.itemHeight)
return Math.max(0, index - props.bufferSize)
})
const endIndex = computed(() => {
const visibleCount = Math.ceil(container.value?.clientHeight / props.itemHeight) || 0
const index = startIndex.value + visibleCount + props.bufferSize
return Math.min(props.data.length, index)
})
const visibleData = computed(() => props.data.slice(startIndex.value, endIndex.value))
const offsetY = computed(() => startIndex.value * props.itemHeight)
const handleScroll = (e) => {
scrollTop.value = e.target.scrollTop
}
</script>
<style scoped>
.virtual-list-container {
height: 400px;
overflow-y: auto;
position: relative;
}
.virtual-list-phantom {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: -1;
}
.virtual-list-content {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.list-item {
height: 50px;
padding: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
</style>