
文章目录
-
- 一、大屏数据可视化概述
-
- [1.1 什么是大屏数据可视化](#1.1 什么是大屏数据可视化)
- [1.2 大屏应用场景](#1.2 大屏应用场景)
- [1.3 技术挑战](#1.3 技术挑战)
- 二、技术栈选型与架构设计
- 三、开发环境搭建与配置
-
- [3.1 Vite项目初始化](#3.1 Vite项目初始化)
- [3.2 Vite配置文件](#3.2 Vite配置文件)
- [3.3 全局样式配置](#3.3 全局样式配置)
- 四、核心组件开发
-
- [4.1 布局组件](#4.1 布局组件)
- [4.2 图表组件封装](#4.2 图表组件封装)
- [4.3 数据卡片组件](#4.3 数据卡片组件)
- 五、数据管理与状态管理
-
- [5.1 Pinia状态管理](#5.1 Pinia状态管理)
- [5.2 API请求封装](#5.2 API请求封装)
- 六、性能优化策略
-
- [6.1 渲染性能优化](#6.1 渲染性能优化)
- [6.2 图表性能优化](#6.2 图表性能优化)
- [6.3 内存管理](#6.3 内存管理)
- 七、动画与交互优化
-
- [7.1 GSAP动画集成](#7.1 GSAP动画集成)
- [7.2 交互反馈优化](#7.2 交互反馈优化)
- 八、部署与监控
-
- [8.1 部署配置](#8.1 部署配置)
- [8.2 性能监控](#8.2 性能监控)
- 九、最佳实践与优化建议
-
- [9.1 开发最佳实践](#9.1 开发最佳实践)
- [9.2 常见问题解决方案](#9.2 常见问题解决方案)
- [9.3 测试策略](#9.3 测试策略)

一、大屏数据可视化概述
1.1 什么是大屏数据可视化
大屏数据可视化是指通过大尺寸显示屏(如指挥中心、监控中心、展览中心等场景)将复杂的数据信息以图形化方式展示的技术方案。它具有以下特点:
- 信息密度高:单屏展示大量数据指标
- 实时性强:数据需要实时或近实时更新
- 视觉效果突出:强调视觉冲击力和美观度
- 交互性有限:以展示为主,交互相对简单
- 多源数据整合:整合来自多个系统的数据源
1.2 大屏应用场景
- 指挥监控中心(交通、电力、安防)
- 企业运营管理驾驶舱
- 展览展示中心
- 数据分析决策平台
- 智慧城市管理平台
1.3 技术挑战
- 多分辨率适配:不同尺寸和分辨率的屏幕适配
- 性能优化:大量数据渲染和动画性能
- 数据实时更新:WebSocket、轮询等实时数据获取
- 视觉一致性:保持整体UI风格统一
- 内存管理:长时间运行的内存泄漏问题
二、技术栈选型与架构设计
2.1 核心技术栈
Vue生态选型
javascript
// package.json 核心依赖示例
{
"dependencies": {
"vue": "^3.2.0", // Vue3组合式API更适合复杂逻辑
"vue-router": "^4.0.0", // 路由管理
"pinia": "^2.0.0", // 状态管理(Vuex替代品)
"axios": "^0.24.0", // HTTP客户端
"echarts": "^5.2.0", // 图表库
"d3.js": "^7.0.0", // 高级数据可视化
"three.js": "^0.138.0", // 3D可视化
"gsap": "^3.9.0", // 高性能动画库
"lodash": "^4.17.21", // 工具函数库
"dayjs": "^1.10.7", // 日期处理
"normalize.css": "^8.0.1" // CSS重置
},
"devDependencies": {
"vite": "^3.0.0", // 构建工具(替代webpack)
"sass": "^1.49.0", // CSS预处理器
"typescript": "^4.5.0", // 类型安全
"eslint": "^8.0.0", // 代码规范
"husky": "^8.0.0", // Git钩子
"commitlint": "^17.0.0" // 提交规范
}
}
UI框架选择考量
- Element Plus:适合后台管理系统,但大屏场景下需要深度定制
- Ant Design Vue:组件丰富,但样式较重
- 自定义组件:大屏项目推荐自定义组件,减少冗余代码
2.2 项目架构设计
project-structure/
├── public/ # 静态资源
├── src/
│ ├── assets/ # 静态资源
│ │ ├── styles/ # 全局样式
│ │ ├── images/ # 图片资源
│ │ └── fonts/ # 字体文件
│ ├── components/ # 公共组件
│ │ ├── charts/ # 图表组件
│ │ ├── maps/ # 地图组件
│ │ ├── cards/ # 卡片组件
│ │ └── layout/ # 布局组件
│ ├── composables/ # 组合式函数
│ │ ├── useEcharts/ # Echarts封装
│ │ ├── useResize/ # 响应式处理
│ │ ├── useWebSocket/ # WebSocket封装
│ │ └── useData/ # 数据处理
│ ├── views/ # 页面组件
│ │ ├── dashboard/ # 主仪表盘
│ │ ├── monitor/ # 监控页面
│ │ └── analysis/ # 分析页面
│ ├── stores/ # Pinia状态管理
│ │ ├── useDataStore/ # 数据状态
│ │ ├── useConfigStore/ # 配置状态
│ │ └── useUserStore/ # 用户状态
│ ├── routers/ # 路由配置
│ ├── apis/ # API接口
│ │ ├── modules/ # 模块化API
│ │ └── request/ # 请求封装
│ ├── utils/ # 工具函数
│ │ ├── common/ # 通用工具
│ │ ├── validators/ # 验证工具
│ │ └── constants/ # 常量定义
│ ├── types/ # TypeScript类型定义
│ ├── plugins/ # 插件
│ └── App.vue # 根组件
└── vite.config.js # Vite配置
2.3 响应式适配方案
核心适配原理
scss
// 全局样式变量
:root {
--design-width: 1920; // 设计稿宽度
--design-height: 1080; // 设计稿高度
--min-width: 1366px; // 最小宽度
--min-height: 768px; // 最小高度
--font-size: 16px; // 基准字体
}
// 响应式适配mixin
@mixin responsive($property, $design-value) {
#{$property}: calc(
#{$design-value} / var(--design-width) * 100vw
);
@media screen and (max-width: var(--min-width)) {
#{$property}: calc(
#{$design-value} / var(--design-width) * var(--min-width)
);
}
}
// 使用示例
.container {
@include responsive(width, 400);
@include responsive(height, 300);
@include responsive(font-size, 24);
}
完整适配方案
typescript
// utils/responsive.ts
export class ResponsiveUtils {
private designWidth: number = 1920
private designHeight: number = 1080
private scale: number = 1
private resizeTimer: number | null = null
constructor(options?: { width?: number; height?: number }) {
if (options) {
this.designWidth = options.width || this.designWidth
this.designHeight = options.height || this.designHeight
}
this.init()
this.bindEvents()
}
private init(): void {
this.calculateScale()
this.applyScale()
}
private bindEvents(): void {
window.addEventListener('resize', this.handleResize.bind(this))
window.addEventListener('orientationchange', this.handleResize.bind(this))
}
private handleResize(): void {
if (this.resizeTimer) {
clearTimeout(this.resizeTimer)
}
this.resizeTimer = window.setTimeout(() => {
this.calculateScale()
this.applyScale()
}, 300)
}
private calculateScale(): void {
const { innerWidth: w, innerHeight: h } = window
const widthScale = w / this.designWidth
const heightScale = h / this.designHeight
// 选择较小的比例保证内容完全显示
this.scale = Math.min(widthScale, heightScale)
// 限制最小缩放比例
this.scale = Math.max(this.scale, 0.5)
}
private applyScale(): void {
const app = document.getElementById('app')
if (!app) return
// 应用缩放
app.style.transform = `scale(${this.scale})`
app.style.transformOrigin = 'center center'
// 计算并设置偏移量
const { innerWidth: w, innerHeight: h } = window
const offsetX = (w - this.designWidth * this.scale) / 2
const offsetY = (h - this.designHeight * this.scale) / 2
app.style.left = `${offsetX}px`
app.style.top = `${offsetY}px`
}
// 像素转换方法
public px2vw(px: number): string {
return `${(px / this.designWidth) * 100}vw`
}
public px2vh(px: number): string {
return `${(px / this.designHeight) * 100}vh`
}
public getCurrentScale(): number {
return this.scale
}
// 响应式字体大小
public responsiveFontSize(min: number, max: number, viewport = 1920): string {
return `clamp(${min}px, ${(max / viewport) * 100}vw, ${max}px)`
}
}
三、开发环境搭建与配置
3.1 Vite项目初始化
bash
# 创建Vue3 + TypeScript项目
npm create vue@latest my-big-screen -- --typescript --router --pinia
# 安装核心依赖
cd my-big-screen
npm install echarts axios normalize.css dayjs lodash-es gsap
npm install sass autoprefixer postcss-px-to-viewport -D
# 安装开发工具
npm install @types/node @types/lodash-es -D
npm install @commitlint/cli @commitlint/config-conventional -D
npm install husky lint-staged -D
3.2 Vite配置文件
typescript
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import autoprefixer from 'autoprefixer'
import px2viewport from 'postcss-px-to-viewport'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'comps': resolve(__dirname, 'src/components'),
'utils': resolve(__dirname, 'src/utils'),
'apis': resolve(__dirname, 'src/apis'),
'stores': resolve(__dirname, 'src/stores')
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: `
@import "@/assets/styles/variables.scss";
@import "@/assets/styles/mixins.scss";
`
}
},
postcss: {
plugins: [
autoprefixer(),
px2viewport({
viewportWidth: 1920, // 设计稿宽度
viewportHeight: 1080, // 设计稿高度
unitPrecision: 5,
viewportUnit: 'vw',
selectorBlackList: ['.ignore-', '.hairlines'],
minPixelValue: 1,
mediaQuery: false,
exclude: /node_modules/
})
]
}
},
server: {
host: '0.0.0.0',
port: 3000,
open: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
'/ws': {
target: 'ws://localhost:8081',
ws: true
}
}
},
build: {
target: 'es2015',
cssTarget: 'chrome80',
outDir: 'dist',
assetsDir: 'assets',
assetsInlineLimit: 4096,
sourcemap: false,
rollupOptions: {
output: {
chunkFileNames: 'js/[name]-[hash].js',
entryFileNames: 'js/[name]-[hash].js',
assetFileNames: '[ext]/[name]-[hash].[ext]',
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'chart-vendor': ['echarts', 'd3'],
'util-vendor': ['lodash-es', 'dayjs', 'axios']
}
}
},
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
},
optimizeDeps: {
include: ['vue', 'vue-router', 'pinia', 'echarts', 'axios']
}
})
3.3 全局样式配置
scss
// assets/styles/variables.scss
// 设计规范变量
$--design-width: 1920px;
$--design-height: 1080px;
$--min-width: 1366px;
$--min-height: 768px;
// 颜色系统
$--color-primary: #409EFF;
$--color-success: #67C23A;
$--color-warning: #E6A23C;
$--color-danger: #F56C6C;
$--color-info: #909399;
// 背景渐变
$--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
$--gradient-success: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
$--gradient-warning: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
// 字体系统
$--font-family: 'Microsoft YaHei', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
$--font-size-extra-large: 20px;
$--font-size-large: 18px;
$--font-size-medium: 16px;
$--font-size-base: 14px;
$--font-size-small: 13px;
$--font-size-extra-small: 12px;
// 间距系统
$--spacing-base: 8px;
$--spacing-small: $--spacing-base * 0.5;
$--spacing-medium: $--spacing-base;
$--spacing-large: $--spacing-base * 1.5;
$--spacing-extra-large: $--spacing-base * 2;
// 边框
$--border-radius-base: 4px;
$--border-radius-circle: 50%;
$--border-width-base: 1px;
$--border-color-base: #DCDFE6;
// 阴影
$--box-shadow-light: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
$--box-shadow-dark: 0 2px 12px 0 rgba(0, 0, 0, 0.3);
// 动画
$--transition-duration: 0.3s;
$--transition-function: cubic-bezier(0.4, 0, 0.2, 1);
// assets/styles/mixins.scss
// 响应式mixin
@mixin responsive($property, $design-value) {
#{$property}: calc(#{$design-value} / #{$--design-width} * 100vw);
@media screen and (max-width: $--min-width) {
#{$property}: calc(#{$design-value} / #{$--design-width} * #{$--min-width});
}
}
// 单行省略
@mixin text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 多行省略
@mixin multi-line-ellipsis($line: 2) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $line;
overflow: hidden;
}
// 渐变文本
@mixin gradient-text($gradient) {
background: $gradient;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
// 卡片样式
@mixin card-container {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: $--border-radius-base;
backdrop-filter: blur(10px);
box-shadow: $--box-shadow-light;
transition: all $--transition-duration $--transition-function;
&:hover {
box-shadow: $--box-shadow-dark;
transform: translateY(-2px);
}
}
// 滚动条样式
@mixin custom-scrollbar {
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
}
}
// assets/styles/global.scss
// 全局样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
font-family: $--font-family;
font-size: $--font-size-base;
line-height: 1.5;
color: #fff;
background: #0a1636;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
position: relative;
width: $--design-width;
height: $--design-height;
margin: 0 auto;
overflow: auto;
@include custom-scrollbar;
}
// 重置Element Plus样式
.el-button {
font-family: $--font-family;
}
// 动画类
.fade-enter-active,
.fade-leave-active {
transition: opacity $--transition-duration $--transition-function;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
// 通用工具类
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.flex { display: flex; }
.flex-column { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.w-full { width: 100%; }
.h-full { height: 100%; }
.mt-1 { margin-top: $--spacing-small; }
.mt-2 { margin-top: $--spacing-medium; }
.mt-3 { margin-top: $--spacing-large; }
.mt-4 { margin-top: $--spacing-extra-large; }
// 响应式工具类
@media screen and (max-width: $--min-width) {
.hide-on-mobile {
display: none !important;
}
}
四、核心组件开发
4.1 布局组件
vue
<!-- components/layout/GridLayout.vue -->
<template>
<div class="grid-layout" :style="gridStyle">
<div
v-for="(item, index) in items"
:key="item.id || index"
class="grid-item"
:style="getItemStyle(item)"
>
<slot name="item" :item="item" :index="index"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, CSSProperties } from 'vue'
interface GridItem {
id?: string
x: number
y: number
w: number
h: number
minW?: number
minH?: number
maxW?: number
maxH?: number
static?: boolean
[key: string]: any
}
interface Props {
items: GridItem[]
cols?: number
rowHeight?: number
margin?: [number, number]
containerPadding?: [number, number]
isDraggable?: boolean
isResizable?: boolean
useCssTransforms?: boolean
}
const props = withDefaults(defineProps<Props>(), {
cols: 24,
rowHeight: 30,
margin: () => [10, 10],
containerPadding: () => [10, 10],
isDraggable: true,
isResizable: true,
useCssTransforms: true
})
// 计算网格样式
const gridStyle = computed<CSSProperties>(() => ({
position: 'relative',
width: '100%',
height: '100%'
}))
// 计算每个项目的样式
const getItemStyle = (item: GridItem): CSSProperties => {
const [marginX, marginY] = props.margin
const [paddingX, paddingY] = props.containerPadding
const colWidth = `calc((100% - ${paddingX * 2}px - ${props.cols * marginX}px) / ${props.cols})`
const x = item.x * (parseInt(colWidth) + marginX) + paddingX
const y = item.y * (props.rowHeight + marginY) + paddingY
const w = item.w * (parseInt(colWidth) + marginX) - marginX
const h = item.h * (props.rowHeight + marginY) - marginY
return {
position: 'absolute',
left: `${x}px`,
top: `${y}px`,
width: `${w}px`,
height: `${h}px`,
transition: 'all 0.3s ease'
}
}
</script>
<style scoped lang="scss">
.grid-layout {
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
overflow: hidden;
}
.grid-item {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
backdrop-filter: blur(10px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:hover {
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
transform: translateY(-2px);
}
&.dragging {
z-index: 1000;
opacity: 0.8;
}
&.resizing {
z-index: 1000;
}
}
</style>
4.2 图表组件封装
vue
<!-- components/charts/BaseChart.vue -->
<template>
<div ref="chartRef" class="base-chart"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import type { EChartsType, EChartsOption } from 'echarts'
import { debounce } from 'lodash-es'
interface Props {
options: EChartsOption
theme?: string | object
initOptions?: any
group?: string
autoResize?: boolean
watchOptions?: boolean
manualUpdate?: boolean
}
const props = withDefaults(defineProps<Props>(), {
theme: 'dark',
autoResize: true,
watchOptions: true,
manualUpdate: false
})
const emit = defineEmits<{
(e: 'init', chart: EChartsType): void
(e: 'click', params: any): void
(e: 'rendered'): void
}>()
const chartRef = ref<HTMLElement>()
let chartInstance: EChartsType | null = null
// 初始化图表
const initChart = async () => {
if (!chartRef.value) return
await nextTick()
chartInstance = echarts.init(chartRef.value, props.theme, props.initOptions)
// 设置图表配置
chartInstance.setOption(props.options, true)
// 绑定事件
chartInstance.on('click', (params) => {
emit('click', params)
})
// 分组管理
if (props.group) {
chartInstance.group = props.group
echarts.connect(props.group)
}
emit('init', chartInstance)
emit('rendered')
}
// 更新图表
const updateChart = (options: EChartsOption) => {
if (!chartInstance) return
chartInstance.setOption(options, !props.manualUpdate)
if (props.manualUpdate) {
chartInstance.hideLoading()
}
}
// 重新渲染图表
const refreshChart = () => {
if (!chartInstance) return
chartInstance.resize()
}
// 销毁图表
const disposeChart = () => {
if (!chartInstance) return
chartInstance.dispose()
chartInstance = null
}
// 响应式处理
const handleResize = debounce(() => {
refreshChart()
}, 300)
// 监听窗口变化
if (props.autoResize) {
window.addEventListener('resize', handleResize)
}
// 生命周期
onMounted(() => {
initChart()
})
onUnmounted(() => {
if (props.autoResize) {
window.removeEventListener('resize', handleResize)
}
disposeChart()
})
// 监听配置变化
watch(
() => props.options,
(newOptions) => {
if (props.watchOptions && !props.manualUpdate) {
updateChart(newOptions)
}
},
{ deep: true }
)
// 暴露方法
defineExpose({
getInstance: () => chartInstance,
update: updateChart,
refresh: refreshChart,
dispose: disposeChart,
showLoading: () => chartInstance?.showLoading(),
hideLoading: () => chartInstance?.hideLoading()
})
</script>
<style scoped lang="scss">
.base-chart {
width: 100%;
height: 100%;
:deep(.echarts) {
width: 100%;
height: 100%;
}
}
</style>
4.3 数据卡片组件
vue
<!-- components/cards/DataCard.vue -->
<template>
<div class="data-card" :class="{ 'has-border': border, 'has-shadow': shadow }">
<!-- 标题区域 -->
<div v-if="title || $slots.title" class="card-header">
<slot name="title">
<div class="card-title">
<div class="title-text">
<span v-if="icon" class="title-icon">
<i :class="icon"></i>
</span>
<span>{{ title }}</span>
</div>
<div v-if="showMore" class="title-extra">
<slot name="extra">
<el-button link @click="$emit('more')">
更多
<el-icon><ArrowRight /></el-icon>
</el-button>
</slot>
</div>
</div>
</slot>
</div>
<!-- 内容区域 -->
<div class="card-body" :style="bodyStyle">
<slot></slot>
</div>
<!-- 底部区域 -->
<div v-if="$slots.footer" class="card-footer">
<slot name="footer"></slot>
</div>
<!-- 装饰元素 -->
<div v-if="decoration" class="card-decoration">
<div class="decoration-corner top-left"></div>
<div class="decoration-corner top-right"></div>
<div class="decoration-corner bottom-left"></div>
<div class="decoration-corner bottom-right"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, CSSProperties } from 'vue'
import { ArrowRight } from '@element-plus/icons-vue'
interface Props {
title?: string
icon?: string
border?: boolean
shadow?: boolean
padding?: string | number
showMore?: boolean
decoration?: boolean
height?: string
backgroundColor?: string
}
const props = withDefaults(defineProps<Props>(), {
border: true,
shadow: true,
padding: '20px',
showMore: false,
decoration: true,
backgroundColor: 'rgba(255, 255, 255, 0.05)'
})
const emit = defineEmits<{
(e: 'more'): void
}>()
// 计算内容区域样式
const bodyStyle = computed<CSSProperties>(() => ({
padding: typeof props.padding === 'number'
? `${props.padding}px`
: props.padding,
height: props.height || 'auto',
backgroundColor: props.backgroundColor
}))
</script>
<style scoped lang="scss">
.data-card {
position: relative;
width: 100%;
height: 100%;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
&.has-border {
border: 1px solid rgba(255, 255, 255, 0.1);
}
&.has-shadow {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.2);
}
}
.card-header {
padding: 16px 20px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.title-text {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 600;
color: #fff;
.title-icon {
margin-right: 8px;
color: var(--color-primary);
i {
font-size: 18px;
}
}
}
.title-extra {
:deep(.el-button) {
color: rgba(255, 255, 255, 0.6);
&:hover {
color: var(--color-primary);
}
}
}
.card-body {
width: 100%;
height: calc(100% - 60px); // 减去标题高度
overflow: auto;
@include custom-scrollbar;
}
.card-footer {
padding: 12px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
}
// 装饰元素
.card-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
overflow: hidden;
.decoration-corner {
position: absolute;
width: 20px;
height: 20px;
&::before,
&::after {
content: '';
position: absolute;
width: 10px;
height: 10px;
border: 2px solid var(--color-primary);
}
&.top-left {
top: 0;
left: 0;
&::before {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
}
}
&.top-right {
top: 0;
right: 0;
&::before {
top: 0;
right: 0;
border-left: none;
border-bottom: none;
}
}
&.bottom-left {
bottom: 0;
left: 0;
&::before {
bottom: 0;
left: 0;
border-right: none;
border-top: none;
}
}
&.bottom-right {
bottom: 0;
right: 0;
&::before {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
}
}
}
}
</style>
五、数据管理与状态管理
5.1 Pinia状态管理
typescript
// stores/useDataStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Ref } from 'vue'
import { fetchDashboardData, fetchRealTimeData, subscribeWebSocket } from '@/apis/dashboard'
import type { DashboardData, RealTimeData, DataConfig } from '@/types/dashboard'
export const useDataStore = defineStore('data', () => {
// 状态定义
const dashboardData: Ref<DashboardData | null> = ref(null)
const realTimeData: Ref<RealTimeData[]> = ref([])
const loading = ref(false)
const error = ref<string | null>(null)
const lastUpdateTime = ref<Date | null>(null)
// 配置
const config = ref<DataConfig>({
autoRefresh: true,
refreshInterval: 5000,
dataSource: 'api',
maxDataPoints: 1000
})
// 计算属性
const isDataReady = computed(() => !!dashboardData.value)
const dataAge = computed(() => {
if (!lastUpdateTime.value) return 0
return Date.now() - lastUpdateTime.value.getTime()
})
// Actions
const fetchData = async (force = false) => {
if (loading.value && !force) return
try {
loading.value = true
error.value = null
const [dashboard, realtime] = await Promise.all([
fetchDashboardData(),
fetchRealTimeData()
])
dashboardData.value = dashboard
realTimeData.value = realtime
lastUpdateTime.value = new Date()
// 限制数据点数量
if (realTimeData.value.length > config.value.maxDataPoints) {
realTimeData.value = realTimeData.value.slice(-config.value.maxDataPoints)
}
} catch (err) {
error.value = err instanceof Error ? err.message : '数据加载失败'
console.error('数据加载失败:', err)
} finally {
loading.value = false
}
}
const updateRealTimeData = (newData: RealTimeData) => {
realTimeData.value.push(newData)
// 限制数据点数量
if (realTimeData.value.length > config.value.maxDataPoints) {
realTimeData.value = realTimeData.value.slice(-config.value.maxDataPoints)
}
lastUpdateTime.value = new Date()
}
const updateConfig = (newConfig: Partial<DataConfig>) => {
config.value = { ...config.value, ...newConfig }
}
const clearData = () => {
dashboardData.value = null
realTimeData.value = []
lastUpdateTime.value = null
error.value = null
}
// WebSocket连接
let ws: WebSocket | null = null
const connectWebSocket = () => {
if (ws) return
ws = subscribeWebSocket({
onMessage: (data) => {
updateRealTimeData(data)
},
onError: (err) => {
error.value = `WebSocket错误: ${err}`
},
onClose: () => {
ws = null
}
})
}
const disconnectWebSocket = () => {
if (ws) {
ws.close()
ws = null
}
}
// 自动刷新
let refreshTimer: number | null = null
const startAutoRefresh = () => {
if (refreshTimer) clearInterval(refreshTimer)
if (config.value.autoRefresh) {
refreshTimer = window.setInterval(() => {
fetchData()
}, config.value.refreshInterval)
}
}
const stopAutoRefresh = () => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
// 初始化
const initialize = () => {
fetchData()
if (config.value.dataSource === 'websocket') {
connectWebSocket()
}
startAutoRefresh()
}
// 清理
const cleanup = () => {
stopAutoRefresh()
disconnectWebSocket()
clearData()
}
return {
// 状态
dashboardData,
realTimeData,
loading,
error,
lastUpdateTime,
config,
// 计算属性
isDataReady,
dataAge,
// Actions
fetchData,
updateRealTimeData,
updateConfig,
clearData,
connectWebSocket,
disconnectWebSocket,
startAutoRefresh,
stopAutoRefresh,
initialize,
cleanup
}
})
5.2 API请求封装
typescript
// apis/request/index.ts
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import router from '@/router'
// 创建axios实例
const createService = (): AxiosInstance => {
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 30000, // 30秒超时
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
})
// 请求拦截器
service.interceptors.request.use(
(config) => {
// 添加token
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 添加请求时间戳,防止缓存
if (config.method?.toLowerCase() === 'get') {
config.params = {
...config.params,
_t: Date.now()
}
}
return config
},
(error) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const { data, config } = response
// 处理业务错误
if (data.code !== 0 && data.code !== 200) {
const errorMsg = data.message || '请求失败'
// 401: 未授权,跳转登录
if (data.code === 401) {
ElMessageBox.alert('登录已过期,请重新登录', '提示', {
confirmButtonText: '重新登录',
callback: () => {
localStorage.removeItem('token')
router.push('/login')
}
})
return Promise.reject(new Error(errorMsg))
}
// 403: 权限不足
if (data.code === 403) {
ElMessage.error('权限不足')
return Promise.reject(new Error(errorMsg))
}
// 其他错误
ElMessage.error(errorMsg)
return Promise.reject(new Error(errorMsg))
}
return data.data || data
},
(error) => {
const { response, config } = error
let errorMessage = '网络错误,请检查网络连接'
if (response) {
switch (response.status) {
case 400:
errorMessage = '请求参数错误'
break
case 401:
errorMessage = '未授权,请重新登录'
localStorage.removeItem('token')
router.push('/login')
break
case 403:
errorMessage = '拒绝访问'
break
case 404:
errorMessage = '请求地址不存在'
break
case 500:
errorMessage = '服务器内部错误'
break
case 502:
errorMessage = '网关错误'
break
case 503:
errorMessage = '服务不可用'
break
case 504:
errorMessage = '网关超时'
break
default:
errorMessage = `请求失败: ${response.status}`
}
} else if (error.code === 'ECONNABORTED') {
errorMessage = '请求超时,请检查网络连接'
} else if (error.message === 'Network Error') {
errorMessage = '网络错误,请检查网络连接'
}
// 不显示GET请求的错误(避免频繁提示)
if (config.method?.toLowerCase() !== 'get') {
ElMessage.error(errorMessage)
}
return Promise.reject(error)
}
)
return service
}
// 创建服务实例
const service = createService()
// 泛型响应类型
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
// 请求方法封装
export const request = {
get<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, { params, ...config })
},
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config)
},
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config)
},
delete<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, { params, ...config })
},
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.patch(url, data, config)
}
}
// WebSocket封装
export class WebSocketService {
private ws: WebSocket | null = null
private url: string
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private reconnectDelay = 3000
private heartbeatInterval: number | null = null
private listeners: Map<string, Function[]> = new Map()
constructor(url: string) {
this.url = url
this.connect()
}
connect(): void {
try {
this.ws = new WebSocket(this.url)
this.setupEventListeners()
} catch (error) {
console.error('WebSocket连接失败:', error)
this.reconnect()
}
}
private setupEventListeners(): void {
if (!this.ws) return
this.ws.onopen = () => {
console.log('WebSocket连接成功')
this.reconnectAttempts = 0
this.startHeartbeat()
this.emit('open')
}
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
this.emit('message', data)
} catch (error) {
console.error('消息解析失败:', error)
}
}
this.ws.onerror = (error) => {
console.error('WebSocket错误:', error)
this.emit('error', error)
}
this.ws.onclose = () => {
console.log('WebSocket连接关闭')
this.stopHeartbeat()
this.emit('close')
this.reconnect()
}
}
private reconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('WebSocket重连次数已达上限')
return
}
this.reconnectAttempts++
console.log(`尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`)
setTimeout(() => {
this.connect()
}, this.reconnectDelay * this.reconnectAttempts)
}
private startHeartbeat(): void {
this.heartbeatInterval = window.setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.send({ type: 'heartbeat' })
}
}, 30000)
}
private stopHeartbeat(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval)
this.heartbeatInterval = null
}
}
send(data: any): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data))
}
}
on(event: string, callback: Function): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, [])
}
this.listeners.get(event)?.push(callback)
}
off(event: string, callback: Function): void {
const callbacks = this.listeners.get(event)
if (callbacks) {
const index = callbacks.indexOf(callback)
if (index > -1) {
callbacks.splice(index, 1)
}
}
}
private emit(event: string, data?: any): void {
const callbacks = this.listeners.get(event)
if (callbacks) {
callbacks.forEach(callback => callback(data))
}
}
close(): void {
this.stopHeartbeat()
this.ws?.close()
this.ws = null
this.listeners.clear()
}
}
export default request
六、性能优化策略
6.1 渲染性能优化
typescript
// composables/useVirtualScroll.ts
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { throttle } from 'lodash-es'
interface VirtualScrollOptions {
itemHeight: number
containerHeight: number
buffer: number
}
export function useVirtualScroll<T>(items: T[], options: VirtualScrollOptions) {
const { itemHeight, containerHeight, buffer = 5 } = options
// 滚动位置
const scrollTop = ref(0)
// 可视区域高度
const visibleHeight = ref(containerHeight)
// 计算总高度
const totalHeight = computed(() => items.length * itemHeight)
// 计算可见项目的起始和结束索引
const visibleRange = computed(() => {
const start = Math.floor(scrollTop.value / itemHeight)
const visibleCount = Math.ceil(visibleHeight.value / itemHeight)
const end = start + visibleCount
// 添加缓冲区域
const bufferStart = Math.max(0, start - buffer)
const bufferEnd = Math.min(items.length, end + buffer)
return {
start: bufferStart,
end: bufferEnd,
offset: bufferStart * itemHeight
}
})
// 可见项目
const visibleItems = computed(() => {
const { start, end } = visibleRange.value
return items.slice(start, end)
})
// 处理滚动
const handleScroll = throttle((event: Event) => {
const target = event.target as HTMLElement
scrollTop.value = target.scrollTop
}, 16) // 60fps
// 更新容器高度
const updateContainerHeight = () => {
const container = document.getElementById('virtual-scroll-container')
if (container) {
visibleHeight.value = container.clientHeight
}
}
// 监听窗口大小变化
const handleResize = throttle(() => {
updateContainerHeight()
}, 200)
// 初始化
onMounted(() => {
updateContainerHeight()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
return {
scrollTop,
totalHeight,
visibleRange,
visibleItems,
handleScroll
}
}
6.2 图表性能优化
typescript
// composables/useChartOptimization.ts
import { ref, onMounted, onUnmounted } from 'vue'
import { debounce } from 'lodash-es'
export function useChartOptimization() {
const isVisible = ref(true)
const visibilityThreshold = 0.1 // 10%可见性阈值
// 检查元素是否在可视区域
const checkVisibility = (element: HTMLElement): boolean => {
const rect = element.getBoundingClientRect()
const windowHeight = window.innerHeight || document.documentElement.clientHeight
// 计算可见比例
const visibleHeight = Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0)
const elementHeight = rect.height
return visibleHeight / elementHeight > visibilityThreshold
}
// 监听图表可见性
const observeChartVisibility = (chartElement: HTMLElement, callback: (visible: boolean) => void) => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
callback(entry.isIntersecting)
})
},
{
threshold: visibilityThreshold,
rootMargin: '50px' // 预加载区域
}
)
observer.observe(chartElement)
return observer
}
// 节流渲染
const throttleRender = (renderFn: Function, delay = 100) => {
let lastCall = 0
let timeoutId: number | null = null
return (...args: any[]) => {
const now = Date.now()
const remaining = delay - (now - lastCall)
if (remaining <= 0) {
lastCall = now
renderFn(...args)
} else if (!timeoutId) {
timeoutId = window.setTimeout(() => {
lastCall = Date.now()
renderFn(...args)
timeoutId = null
}, remaining)
}
}
}
// 数据采样(用于大数据集)
const sampleData = <T>(data: T[], maxPoints: number): T[] => {
if (data.length <= maxPoints) return data
const sampled = []
const step = data.length / maxPoints
for (let i = 0; i < maxPoints; i++) {
const index = Math.floor(i * step)
sampled.push(data[index])
}
return sampled
}
// 内存清理
const cleanupChart = (chartInstance: any) => {
if (chartInstance) {
chartInstance.dispose()
// 强制垃圾回收提示
if (window.gc) {
window.gc()
}
}
}
return {
isVisible,
checkVisibility,
observeChartVisibility,
throttleRender,
sampleData,
cleanupChart
}
}
6.3 内存管理
typescript
// utils/memoryManager.ts
export class MemoryManager {
private static instance: MemoryManager
private cache: Map<string, any> = new Map()
private maxCacheSize: number = 100
private cleanupInterval: number = 300000 // 5分钟清理一次
private constructor() {
this.startCleanupTimer()
}
static getInstance(): MemoryManager {
if (!MemoryManager.instance) {
MemoryManager.instance = new MemoryManager()
}
return MemoryManager.instance
}
// 设置缓存
set(key: string, value: any, ttl?: number): void {
this.cache.set(key, {
value,
timestamp: Date.now(),
ttl: ttl || 0
})
// 检查缓存大小
if (this.cache.size > this.maxCacheSize) {
this.cleanupOldest()
}
}
// 获取缓存
get(key: string): any {
const item = this.cache.get(key)
if (!item) return null
// 检查是否过期
if (item.ttl > 0 && Date.now() - item.timestamp > item.ttl) {
this.cache.delete(key)
return null
}
return item.value
}
// 删除缓存
delete(key: string): void {
this.cache.delete(key)
}
// 清理最旧的缓存
private cleanupOldest(): void {
if (this.cache.size <= this.maxCacheSize * 0.8) return
const entries = Array.from(this.cache.entries())
// 按时间戳排序
entries.sort((a, b) => a[1].timestamp - b[1].timestamp)
// 删除最旧的20%
const deleteCount = Math.floor(this.cache.size * 0.2)
for (let i = 0; i < deleteCount; i++) {
this.cache.delete(entries[i][0])
}
}
// 清理过期的缓存
private cleanupExpired(): void {
const now = Date.now()
for (const [key, item] of this.cache.entries()) {
if (item.ttl > 0 && now - item.timestamp > item.ttl) {
this.cache.delete(key)
}
}
}
// 启动清理定时器
private startCleanupTimer(): void {
setInterval(() => {
this.cleanupExpired()
}, this.cleanupInterval)
}
// 清空所有缓存
clear(): void {
this.cache.clear()
}
// 获取缓存统计信息
getStats(): {
size: number
maxSize: number
hitRate: number
} {
// 这里可以添加命中率统计逻辑
return {
size: this.cache.size,
maxSize: this.maxCacheSize,
hitRate: 0
}
}
}
七、动画与交互优化
7.1 GSAP动画集成
typescript
// composables/useAnimation.ts
import { ref, onMounted, onUnmounted } from 'vue'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { TextPlugin } from 'gsap/TextPlugin'
// 注册GSAP插件
gsap.registerPlugin(ScrollTrigger, TextPlugin)
export function useAnimation() {
const timeline = ref<gsap.core.Timeline | null>(null)
// 数字计数动画
const animateNumber = (
element: HTMLElement,
targetValue: number,
duration: number = 2
): gsap.core.Tween => {
return gsap.to(element, {
innerHTML: targetValue,
duration,
snap: { innerHTML: 1 },
ease: 'power2.out'
})
}
// 文字打字机效果
const typewriter = (
element: HTMLElement,
text: string,
duration: number = 1
): gsap.core.Tween => {
return gsap.to(element, {
duration,
text: text,
ease: 'none'
})
}
// 卡片入场动画
const cardEnterAnimation = (
element: HTMLElement,
delay: number = 0
): gsap.core.Tween => {
return gsap.from(element, {
y: 50,
opacity: 0,
duration: 0.6,
delay,
ease: 'back.out(1.7)'
})
}
// 图表渲染动画
const chartRenderAnimation = (
element: HTMLElement,
from: number = 0,
to: number = 1
): gsap.core.Tween => {
return gsap.fromTo(
element,
{ scaleY: from, transformOrigin: 'bottom center' },
{
scaleY: to,
duration: 1.5,
ease: 'elastic.out(1, 0.5)'
}
)
}
// 创建时间线
const createTimeline = (): gsap.core.Timeline => {
const tl = gsap.timeline()
timeline.value = tl
return tl
}
// 滚动触发动画
const scrollTriggerAnimation = (
element: HTMLElement,
animation: gsap.core.Tween,
options?: ScrollTrigger.Vars
): ScrollTrigger => {
return ScrollTrigger.create({
trigger: element,
start: 'top 80%',
end: 'bottom 20%',
toggleActions: 'play none none reverse',
...options,
animation
})
}
// 粒子动画
const particleAnimation = (
container: HTMLElement,
count: number = 50
): void => {
for (let i = 0; i < count; i++) {
const particle = document.createElement('div')
particle.className = 'particle'
container.appendChild(particle)
gsap.set(particle, {
x: gsap.utils.random(0, container.offsetWidth),
y: gsap.utils.random(0, container.offsetHeight),
scale: gsap.utils.random(0.1, 0.5),
opacity: gsap.utils.random(0.1, 0.5)
})
gsap.to(particle, {
x: '+=random(-50, 50)',
y: '+=random(-50, 50)',
duration: gsap.utils.random(2, 4),
repeat: -1,
yoyo: true,
ease: 'sine.inOut'
})
}
}
// 清理动画
const cleanup = (): void => {
if (timeline.value) {
timeline.value.kill()
timeline.value = null
}
ScrollTrigger.getAll().forEach(trigger => {
trigger.kill()
})
}
onUnmounted(() => {
cleanup()
})
return {
timeline,
animateNumber,
typewriter,
cardEnterAnimation,
chartRenderAnimation,
createTimeline,
scrollTriggerAnimation,
particleAnimation,
cleanup
}
}
7.2 交互反馈优化
vue
<!-- components/interactive/HoverCard.vue -->
<template>
<div
ref="cardRef"
class="hover-card"
:class="{
'is-hovering': isHovering,
'is-active': isActive
}"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
>
<div class="card-content">
<slot></slot>
</div>
<!-- 高光效果 -->
<div ref="highlightRef" class="card-highlight"></div>
<!-- 点击涟漪效果 -->
<div
v-for="ripple in ripples"
:key="ripple.id"
class="ripple-effect"
:style="ripple.style"
></div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import gsap from 'gsap'
interface Props {
highlight?: boolean
ripple?: boolean
hoverScale?: number
activeScale?: number
}
const props = withDefaults(defineProps<Props>(), {
highlight: true,
ripple: true,
hoverScale: 1.05,
activeScale: 0.98
})
const emit = defineEmits<{
(e: 'hover', isHovering: boolean): void
(e: 'click'): void
}>()
const cardRef = ref<HTMLElement>()
const highlightRef = ref<HTMLElement>()
const isHovering = ref(false)
const isActive = ref(false)
// 涟漪效果
interface Ripple {
id: number
style: {
left: string
top: string
width: string
height: string
}
}
const ripples = reactive<Ripple[]>([])
let rippleId = 0
// 鼠标进入
const handleMouseEnter = () => {
isHovering.value = true
emit('hover', true)
if (cardRef.value) {
gsap.to(cardRef.value, {
scale: props.hoverScale,
duration: 0.3,
ease: 'back.out(1.7)'
})
}
// 高光效果
if (props.highlight && highlightRef.value) {
gsap.to(highlightRef.value, {
opacity: 1,
duration: 0.3
})
}
}
// 鼠标离开
const handleMouseLeave = () => {
isHovering.value = false
isActive.value = false
emit('hover', false)
if (cardRef.value) {
gsap.to(cardRef.value, {
scale: 1,
duration: 0.3,
ease: 'power2.out'
})
}
// 隐藏高光
if (props.highlight && highlightRef.value) {
gsap.to(highlightRef.value, {
opacity: 0,
duration: 0.3
})
}
}
// 鼠标按下
const handleMouseDown = (event: MouseEvent) => {
isActive.value = true
if (cardRef.value) {
gsap.to(cardRef.value, {
scale: props.activeScale,
duration: 0.1
})
}
// 创建涟漪效果
if (props.ripple && cardRef.value) {
const rect = cardRef.value.getBoundingClientRect()
const size = Math.max(rect.width, rect.height)
const x = event.clientX - rect.left - size / 2
const y = event.clientY - rect.top - size / 2
const ripple: Ripple = {
id: rippleId++,
style: {
left: `${x}px`,
top: `${y}px`,
width: `${size}px`,
height: `${size}px`
}
}
ripples.push(ripple)
// 动画效果
nextTick(() => {
const rippleElement = document.querySelector(`[data-ripple-id="${ripple.id}"]`)
if (rippleElement) {
gsap.to(rippleElement, {
scale: 2,
opacity: 0,
duration: 0.6,
ease: 'power2.out',
onComplete: () => {
const index = ripples.findIndex(r => r.id === ripple.id)
if (index > -1) {
ripples.splice(index, 1)
}
}
})
}
})
}
}
// 鼠标释放
const handleMouseUp = () => {
isActive.value = false
emit('click')
if (cardRef.value) {
gsap.to(cardRef.value, {
scale: isHovering.value ? props.hoverScale : 1,
duration: 0.2,
ease: 'back.out(1.7)'
})
}
}
// 键盘交互
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleMouseDown(event as any)
}
}
const handleKeyup = (event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleMouseUp()
}
}
onMounted(() => {
if (cardRef.value) {
cardRef.value.addEventListener('keydown', handleKeydown)
cardRef.value.addEventListener('keyup', handleKeyup)
cardRef.value.tabIndex = 0
}
})
onUnmounted(() => {
if (cardRef.value) {
cardRef.value.removeEventListener('keydown', handleKeydown)
cardRef.value.removeEventListener('keyup', handleKeyup)
}
})
</script>
<style scoped lang="scss">
.hover-card {
position: relative;
cursor: pointer;
transition: transform 0.3s ease;
transform-origin: center center;
outline: none;
&:focus-visible {
box-shadow: 0 0 0 2px var(--color-primary);
}
}
.card-content {
position: relative;
z-index: 2;
}
.card-highlight {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.05) 100%
);
border-radius: inherit;
opacity: 0;
z-index: 1;
pointer-events: none;
transition: opacity 0.3s ease;
}
.ripple-effect {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: scale(0);
z-index: 1;
pointer-events: none;
}
</style>
八、部署与监控
8.1 部署配置
nginx
# nginx.conf
server {
listen 80;
server_name your-domain.com;
# 开启gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/octet-stream;
# 静态资源缓存
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
# 开启Brotli压缩(如果支持)
brotli on;
brotli_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/octet-stream;
}
# 主应用
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' https: data: 'unsafe-inline' 'unsafe-eval'" always;
}
# API代理
location /api/ {
proxy_pass http://api-server:3000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# WebSocket代理
location /ws/ {
proxy_pass http://ws-server:3001/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# 错误页面
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
}
8.2 性能监控
typescript
// utils/performanceMonitor.ts
export class PerformanceMonitor {
private metrics: Map<string, any[]> = new Map()
private isMonitoring = false
// 开始监控
start(): void {
if (this.isMonitoring) return
this.isMonitoring = true
this.setupPerformanceObserver()
this.setupErrorTracking()
this.setupResourceTiming()
}
// 停止监控
stop(): void {
this.isMonitoring = false
}
// 设置性能观察者
private setupPerformanceObserver(): void {
// 监控长任务
if ('PerformanceObserver' in window) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
this.recordMetric('longtasks', {
duration: entry.duration,
startTime: entry.startTime,
name: entry.name
})
}
})
observer.observe({ entryTypes: ['longtask'] })
}
// 监控绘制时间
const measureFP = () => {
const fp = performance.getEntriesByName('first-paint')[0]
const fcp = performance.getEntriesByName('first-contentful-paint')[0]
if (fp) {
this.recordMetric('first-paint', fp.startTime)
}
if (fcp) {
this.recordMetric('first-contentful-paint', fcp.startTime)
}
}
// 监控LCP
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'largest-contentful-paint') {
this.recordMetric('lcp', entry.startTime)
}
}
})
observer.observe({ entryTypes: ['largest-contentful-paint'] })
// 监听页面加载完成
window.addEventListener('load', () => {
measureFP()
// 监控其他指标
setTimeout(() => {
this.measurePerformance()
}, 1000)
})
}
// 测量性能指标
private measurePerformance(): void {
// FPS监控
this.monitorFPS()
// 内存使用监控
this.monitorMemory()
// 布局偏移监控
this.monitorLayoutShift()
}
// 监控FPS
private monitorFPS(): void {
let frameCount = 0
let lastTime = performance.now()
const checkFPS = () => {
frameCount++
const currentTime = performance.now()
if (currentTime - lastTime >= 1000) {
const fps = Math.round((frameCount * 1000) / (currentTime - lastTime))
this.recordMetric('fps', fps)
frameCount = 0
lastTime = currentTime
}
if (this.isMonitoring) {
requestAnimationFrame(checkFPS)
}
}
requestAnimationFrame(checkFPS)
}
// 监控内存使用
private monitorMemory(): void {
if ('memory' in performance) {
const memory = (performance as any).memory
setInterval(() => {
this.recordMetric('memory', {
usedJSHeapSize: memory.usedJSHeapSize,
totalJSHeapSize: memory.totalJSHeapSize,
jsHeapSizeLimit: memory.jsHeapSizeLimit
})
}, 10000)
}
}
// 监控布局偏移
private monitorLayoutShift(): void {
let cls = 0
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!(entry as any).hadRecentInput) {
cls += (entry as any).value
}
}
this.recordMetric('cls', cls)
})
observer.observe({ entryTypes: ['layout-shift'] })
}
// 错误追踪
private setupErrorTracking(): void {
// JavaScript错误
window.addEventListener('error', (event) => {
this.recordMetric('error', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error?.stack
})
})
// Promise错误
window.addEventListener('unhandledrejection', (event) => {
this.recordMetric('promise-error', {
reason: event.reason?.message || event.reason,
stack: event.reason?.stack
})
})
// 资源加载错误
window.addEventListener('error', (event) => {
const target = event.target as HTMLElement
if (target.tagName === 'IMG' ||
target.tagName === 'SCRIPT' ||
target.tagName === 'LINK') {
this.recordMetric('resource-error', {
tagName: target.tagName,
src: (target as any).src || (target as any).href
})
}
}, true)
}
// 资源计时
private setupResourceTiming(): void {
// 监控所有资源的加载时间
const resources = performance.getEntriesByType('resource')
resources.forEach(resource => {
this.recordMetric('resource-timing', {
name: resource.name,
duration: resource.duration,
initiatorType: resource.initiatorType,
transferSize: resource.transferSize
})
})
}
// 记录指标
private recordMetric(name: string, value: any): void {
if (!this.metrics.has(name)) {
this.metrics.set(name, [])
}
const metric = this.metrics.get(name)!
metric.push({
value,
timestamp: Date.now()
})
// 限制存储数量
if (metric.length > 1000) {
metric.shift()
}
// 触发告警
this.checkAlert(name, value)
}
// 检查告警
private checkAlert(name: string, value: any): void {
const alerts = {
'fps': { threshold: 30, type: 'low' },
'memory': { threshold: 0.8, type: 'high' }, // 80%内存使用
'lcp': { threshold: 2500, type: 'high' } // 2.5秒
}
const alertConfig = (alerts as any)[name]
if (!alertConfig) return
let shouldAlert = false
if (name === 'fps' && value < alertConfig.threshold) {
shouldAlert = true
} else if (name === 'memory' && value.usedJSHeapSize / value.jsHeapSizeLimit > alertConfig.threshold) {
shouldAlert = true
} else if (name === 'lcp' && value > alertConfig.threshold) {
shouldAlert = true
}
if (shouldAlert) {
this.triggerAlert(name, value, alertConfig)
}
}
// 触发告警
private triggerAlert(name: string, value: any, config: any): void {
console.warn(`性能告警: ${name} = ${value} ${config.type === 'low' ? '低于' : '高于'}阈值`)
// 这里可以集成到监控系统
// 例如:发送到服务器、显示通知等
}
// 获取性能报告
getReport(): Record<string, any> {
const report: Record<string, any> = {}
for (const [name, values] of this.metrics) {
if (values.length === 0) continue
const numericValues = values
.map(v => v.value)
.filter(v => typeof v === 'number')
if (numericValues.length > 0) {
report[name] = {
avg: numericValues.reduce((a, b) => a + b, 0) / numericValues.length,
min: Math.min(...numericValues),
max: Math.max(...numericValues),
latest: values[values.length - 1].value,
count: values.length
}
} else {
report[name] = {
latest: values[values.length - 1].value,
count: values.length
}
}
}
return report
}
// 清理数据
clear(): void {
this.metrics.clear()
}
}
九、最佳实践与优化建议
9.1 开发最佳实践
-
组件设计原则
- 单一职责原则:每个组件只负责一个功能
- 可复用性:提取通用组件和逻辑
- 可维护性:清晰的命名和结构
-
代码规范
- 使用TypeScript保证类型安全
- 遵循ESLint规范
- 统一的命名约定
-
性能优化
- 按需加载组件
- 合理使用缓存
- 避免不必要的重新渲染
9.2 常见问题解决方案
-
内存泄漏
typescript// 使用WeakMap避免内存泄漏 const weakMap = new WeakMap<Element, any>() // 及时清理定时器 const timer = setInterval(() => {}, 1000) onUnmounted(() => clearInterval(timer)) // 移除事件监听器 const handleResize = () => {} window.addEventListener('resize', handleResize) onUnmounted(() => window.removeEventListener('resize', handleResize)) -
大屏适配
typescript// 多种适配方案结合 // 1. 缩放方案:适合固定分辨率 // 2. 响应式方案:适合多分辨率 // 3. 混合方案:根据场景选择 -
数据更新策略
typescript// 增量更新 const updateDataIncrementally = (newData: any[]) => { // 只更新变化的数据 // 避免全量刷新 } // 节流更新 const throttledUpdate = throttle(updateData, 1000) // 批量更新 const batchUpdate = () => { // 收集多次更新,一次性应用 }
9.3 测试策略
-
单元测试
typescript// 使用Vitest进行单元测试 import { describe, it, expect } from 'vitest' import { mount } from '@vue/test-utils' describe('ChartComponent', () => { it('should render correctly', () => { const wrapper = mount(ChartComponent) expect(wrapper.exists()).toBe(true) }) }) -
性能测试
typescript// 使用Lighthouse进行性能测试 // 自定义性能测试脚本 const runPerformanceTest = async () => { const metrics = await getPerformanceMetrics() assert(metrics.fps > 30, 'FPS应大于30') } -
集成测试
typescript// 使用Cypress进行端到端测试 describe('Dashboard', () => { it('should load all charts', () => { cy.visit('/dashboard') cy.get('.chart-container').should('have.length', 6) }) })
Vue大屏开发是一个系统工程,需要综合考虑技术选型、架构设计、性能优化、用户体验等多个方面。
希望本文能为Vue大屏开发项目提供有价值的参考和指导。祝您开发顺利!