Vue大屏开发全流程及技术细节详解

文章目录

一、大屏数据可视化概述

1.1 什么是大屏数据可视化

大屏数据可视化是指通过大尺寸显示屏(如指挥中心、监控中心、展览中心等场景)将复杂的数据信息以图形化方式展示的技术方案。它具有以下特点:

  • 信息密度高:单屏展示大量数据指标
  • 实时性强:数据需要实时或近实时更新
  • 视觉效果突出:强调视觉冲击力和美观度
  • 交互性有限:以展示为主,交互相对简单
  • 多源数据整合:整合来自多个系统的数据源

1.2 大屏应用场景

  • 指挥监控中心(交通、电力、安防)
  • 企业运营管理驾驶舱
  • 展览展示中心
  • 数据分析决策平台
  • 智慧城市管理平台

1.3 技术挑战

  1. 多分辨率适配:不同尺寸和分辨率的屏幕适配
  2. 性能优化:大量数据渲染和动画性能
  3. 数据实时更新:WebSocket、轮询等实时数据获取
  4. 视觉一致性:保持整体UI风格统一
  5. 内存管理:长时间运行的内存泄漏问题

二、技术栈选型与架构设计

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框架选择考量
  1. Element Plus:适合后台管理系统,但大屏场景下需要深度定制
  2. Ant Design Vue:组件丰富,但样式较重
  3. 自定义组件:大屏项目推荐自定义组件,减少冗余代码

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 开发最佳实践

  1. 组件设计原则

    • 单一职责原则:每个组件只负责一个功能
    • 可复用性:提取通用组件和逻辑
    • 可维护性:清晰的命名和结构
  2. 代码规范

    • 使用TypeScript保证类型安全
    • 遵循ESLint规范
    • 统一的命名约定
  3. 性能优化

    • 按需加载组件
    • 合理使用缓存
    • 避免不必要的重新渲染

9.2 常见问题解决方案

  1. 内存泄漏

    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))
  2. 大屏适配

    typescript 复制代码
    // 多种适配方案结合
    // 1. 缩放方案:适合固定分辨率
    // 2. 响应式方案:适合多分辨率
    // 3. 混合方案:根据场景选择
  3. 数据更新策略

    typescript 复制代码
    // 增量更新
    const updateDataIncrementally = (newData: any[]) => {
      // 只更新变化的数据
      // 避免全量刷新
    }
    
    // 节流更新
    const throttledUpdate = throttle(updateData, 1000)
    
    // 批量更新
    const batchUpdate = () => {
      // 收集多次更新,一次性应用
    }

9.3 测试策略

  1. 单元测试

    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)
      })
    })
  2. 性能测试

    typescript 复制代码
    // 使用Lighthouse进行性能测试
    // 自定义性能测试脚本
    const runPerformanceTest = async () => {
      const metrics = await getPerformanceMetrics()
      assert(metrics.fps > 30, 'FPS应大于30')
    }
  3. 集成测试

    typescript 复制代码
    // 使用Cypress进行端到端测试
    describe('Dashboard', () => {
      it('should load all charts', () => {
        cy.visit('/dashboard')
        cy.get('.chart-container').should('have.length', 6)
      })
    })

Vue大屏开发是一个系统工程,需要综合考虑技术选型、架构设计、性能优化、用户体验等多个方面。

希望本文能为Vue大屏开发项目提供有价值的参考和指导。祝您开发顺利!

相关推荐
Elastic 中国社区官方博客几秒前
使用 Elastic Agent Builder 和 MCP 实现 Agentic 参考架构
大数据·人工智能·elasticsearch·搜索引擎·ai·架构·全文检索
AI前端老薛7 分钟前
CSS实现动画的几种方式
前端·css
开发加微信:hedian1168 分钟前
推客与分销场景下的系统架构实践:如何支撑高并发与复杂业务规则
小程序
携欢11 分钟前
portswigger靶场之修改序列化数据类型通关秘籍
android·前端·网络·安全
前端小L12 分钟前
专题二:核心机制 —— reactive 与 effect
javascript·源码·vue3
GuMoYu12 分钟前
npm link 测试本地依赖完整指南
前端·npm
代码老祖12 分钟前
vue3 vue-pdf-embed实现pdf自定义分页+关键词高亮
前端·javascript
未等与你踏清风13 分钟前
Elpis npm 包抽离总结
前端·javascript
代码猎人13 分钟前
如何使用for...of遍历对象
前端
秋天的一阵风15 分钟前
🎥解决前端 “复现难”:rrweb 录制回放从入门到精通(下)
前端·开源·全栈