Vue進(jìn)行大屏開發(fā)的全流程及技術(shù)細(xì)節(jié)詳解
一、大屏數(shù)據(jù)可視化概述
1.1 什么是大屏數(shù)據(jù)可視化
大屏數(shù)據(jù)可視化是指通過大尺寸顯示屏(如指揮中心、監(jiān)控中心、展覽中心等場(chǎng)景)將復(fù)雜的數(shù)據(jù)信息以圖形化方式展示的技術(shù)方案。它具有以下特點(diǎn):
- 信息密度高:?jiǎn)纹琳故敬罅繑?shù)據(jù)指標(biāo)
- 實(shí)時(shí)性強(qiáng):數(shù)據(jù)需要實(shí)時(shí)或近實(shí)時(shí)更新
- 視覺效果突出:強(qiáng)調(diào)視覺沖擊力和美觀度
- 交互性有限:以展示為主,交互相對(duì)簡(jiǎn)單
- 多源數(shù)據(jù)整合:整合來自多個(gè)系統(tǒng)的數(shù)據(jù)源
1.2 大屏應(yīng)用場(chǎng)景
- 指揮監(jiān)控中心(交通、電力、安防)
- 企業(yè)運(yùn)營(yíng)管理駕駛艙
- 展覽展示中心
- 數(shù)據(jù)分析決策平臺(tái)
- 智慧城市管理平臺(tái)
1.3 技術(shù)挑戰(zhàn)
- 多分辨率適配:不同尺寸和分辨率的屏幕適配
- 性能優(yōu)化:大量數(shù)據(jù)渲染和動(dòng)畫性能
- 數(shù)據(jù)實(shí)時(shí)更新:WebSocket、輪詢等實(shí)時(shí)數(shù)據(jù)獲取
- 視覺一致性:保持整體UI風(fēng)格統(tǒng)一
- 內(nèi)存管理:長(zhǎng)時(shí)間運(yùn)行的內(nèi)存泄漏問題

二、技術(shù)棧選型與架構(gòu)設(shè)計(jì)
2.1 核心技術(shù)棧
Vue生態(tài)選型
// package.json 核心依賴示例
{
"dependencies": {
"vue": "^3.2.0", // Vue3組合式API更適合復(fù)雜邏輯
"vue-router": "^4.0.0", // 路由管理
"pinia": "^2.0.0", // 狀態(tài)管理(Vuex替代品)
"axios": "^0.24.0", // HTTP客戶端
"echarts": "^5.2.0", // 圖表庫(kù)
"d3.js": "^7.0.0", // 高級(jí)數(shù)據(jù)可視化
"three.js": "^0.138.0", // 3D可視化
"gsap": "^3.9.0", // 高性能動(dòng)畫庫(kù)
"lodash": "^4.17.21", // 工具函數(shù)庫(kù)
"dayjs": "^1.10.7", // 日期處理
"normalize.css": "^8.0.1" // CSS重置
},
"devDependencies": {
"vite": "^3.0.0", // 構(gòu)建工具(替代webpack)
"sass": "^1.49.0", // CSS預(yù)處理器
"typescript": "^4.5.0", // 類型安全
"eslint": "^8.0.0", // 代碼規(guī)范
"husky": "^8.0.0", // Git鉤子
"commitlint": "^17.0.0" // 提交規(guī)范
}
}
UI框架選擇考量
- Element Plus:適合后臺(tái)管理系統(tǒng),但大屏場(chǎng)景下需要深度定制
- Ant Design Vue:組件豐富,但樣式較重
- 自定義組件:大屏項(xiàng)目推薦自定義組件,減少冗余代碼
2.2 項(xiàng)目架構(gòu)設(shè)計(jì)
project-structure/
├── public/ # 靜態(tài)資源
├── src/
│ ├── assets/ # 靜態(tài)資源
│ │ ├── styles/ # 全局樣式
│ │ ├── images/ # 圖片資源
│ │ └── fonts/ # 字體文件
│ ├── components/ # 公共組件
│ │ ├── charts/ # 圖表組件
│ │ ├── maps/ # 地圖組件
│ │ ├── cards/ # 卡片組件
│ │ └── layout/ # 布局組件
│ ├── composables/ # 組合式函數(shù)
│ │ ├── useEcharts/ # Echarts封裝
│ │ ├── useResize/ # 響應(yīng)式處理
│ │ ├── useWebSocket/ # WebSocket封裝
│ │ └── useData/ # 數(shù)據(jù)處理
│ ├── views/ # 頁(yè)面組件
│ │ ├── dashboard/ # 主儀表盤
│ │ ├── monitor/ # 監(jiān)控頁(yè)面
│ │ └── analysis/ # 分析頁(yè)面
│ ├── stores/ # Pinia狀態(tài)管理
│ │ ├── useDataStore/ # 數(shù)據(jù)狀態(tài)
│ │ ├── useConfigStore/ # 配置狀態(tài)
│ │ └── useUserStore/ # 用戶狀態(tài)
│ ├── routers/ # 路由配置
│ ├── apis/ # API接口
│ │ ├── modules/ # 模塊化API
│ │ └── request/ # 請(qǐng)求封裝
│ ├── utils/ # 工具函數(shù)
│ │ ├── common/ # 通用工具
│ │ ├── validators/ # 驗(yàn)證工具
│ │ └── constants/ # 常量定義
│ ├── types/ # TypeScript類型定義
│ ├── plugins/ # 插件
│ └── App.vue # 根組件
└── vite.config.js # Vite配置
2.3 響應(yīng)式適配方案
核心適配原理
// 全局樣式變量
:root {
--design-width: 1920; // 設(shè)計(jì)稿寬度
--design-height: 1080; // 設(shè)計(jì)稿高度
--min-width: 1366px; // 最小寬度
--min-height: 768px; // 最小高度
--font-size: 16px; // 基準(zhǔn)字體
}
// 響應(yīng)式適配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);
}
完整適配方案
// 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
// 選擇較小的比例保證內(nèi)容完全顯示
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
// 應(yīng)用縮放
app.style.transform = `scale(${this.scale})`
app.style.transformOrigin = 'center center'
// 計(jì)算并設(shè)置偏移量
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`
}
// 像素轉(zhuǎn)換方法
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
}
// 響應(yīng)式字體大小
public responsiveFontSize(min: number, max: number, viewport = 1920): string {
return `clamp(${min}px, ${(max / viewport) * 100}vw, ${max}px)`
}
}
三、開發(fā)環(huán)境搭建與配置
3.1 Vite項(xiàng)目初始化
# 創(chuàng)建Vue3 + TypeScript項(xiàng)目 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 # 安裝開發(fā)工具 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配置文件
// 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, // 設(shè)計(jì)稿寬度
viewportHeight: 1080, // 設(shè)計(jì)稿高度
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 全局樣式配置
// assets/styles/variables.scss
// 設(shè)計(jì)規(guī)范變量
$--design-width: 1920px;
$--design-height: 1080px;
$--min-width: 1366px;
$--min-height: 768px;
// 顏色系統(tǒng)
$--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%);
// 字體系統(tǒng)
$--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;
// 間距系統(tǒng)
$--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);
// 動(dòng)畫
$--transition-duration: 0.3s;
$--transition-function: cubic-bezier(0.4, 0, 0.2, 1);
// assets/styles/mixins.scss
// 響應(yīng)式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);
}
}
// 滾動(dòng)條樣式
@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;
}
// 動(dòng)畫類
.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; }
// 響應(yīng)式工具類
@media screen and (max-width: $--min-width) {
.hide-on-mobile {
display: none !important;
}
}
四、核心組件開發(fā)
4.1 布局組件
<!-- 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
})
// 計(jì)算網(wǎng)格樣式
const gridStyle = computed<CSSProperties>(() => ({
position: 'relative',
width: '100%',
height: '100%'
}))
// 計(jì)算每個(gè)項(xiàng)目的樣式
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 圖表組件封裝
<!-- 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)
// 設(shè)置圖表配置
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
}
// 響應(yīng)式處理
const handleResize = debounce(() => {
refreshChart()
}, 300)
// 監(jiān)聽窗口變化
if (props.autoResize) {
window.addEventListener('resize', handleResize)
}
// 生命周期
onMounted(() => {
initChart()
})
onUnmounted(() => {
if (props.autoResize) {
window.removeEventListener('resize', handleResize)
}
disposeChart()
})
// 監(jiān)聽配置變化
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 數(shù)據(jù)卡片組件
<!-- components/cards/DataCard.vue -->
<template>
<div class="data-card" :class="{ 'has-border': border, 'has-shadow': shadow }">
<!-- 標(biāo)題區(qū)域 -->
<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>
<!-- 內(nèi)容區(qū)域 -->
<div class="card-body" :style="bodyStyle">
<slot></slot>
</div>
<!-- 底部區(qū)域 -->
<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
}>()
// 計(jì)算內(nèi)容區(qū)域樣式
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); // 減去標(biāo)題高度
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>
五、數(shù)據(jù)管理與狀態(tài)管理
5.1 Pinia狀態(tài)管理
// 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', () => {
// 狀態(tài)定義
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
})
// 計(jì)算屬性
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()
// 限制數(shù)據(jù)點(diǎn)數(shù)量
if (realTimeData.value.length > config.value.maxDataPoints) {
realTimeData.value = realTimeData.value.slice(-config.value.maxDataPoints)
}
} catch (err) {
error.value = err instanceof Error ? err.message : '數(shù)據(jù)加載失敗'
console.error('數(shù)據(jù)加載失敗:', err)
} finally {
loading.value = false
}
}
const updateRealTimeData = (newData: RealTimeData) => {
realTimeData.value.push(newData)
// 限制數(shù)據(jù)點(diǎn)數(shù)量
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錯(cuò)誤: ${err}`
},
onClose: () => {
ws = null
}
})
}
const disconnectWebSocket = () => {
if (ws) {
ws.close()
ws = null
}
}
// 自動(dòng)刷新
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 {
// 狀態(tài)
dashboardData,
realTimeData,
loading,
error,
lastUpdateTime,
config,
// 計(jì)算屬性
isDataReady,
dataAge,
// Actions
fetchData,
updateRealTimeData,
updateConfig,
clearData,
connectWebSocket,
disconnectWebSocket,
startAutoRefresh,
stopAutoRefresh,
initialize,
cleanup
}
})
5.2 API請(qǐng)求封裝
// 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'
// 創(chuàng)建axios實(shí)例
const createService = (): AxiosInstance => {
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 30000, // 30秒超時(shí)
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
})
// 請(qǐng)求攔截器
service.interceptors.request.use(
(config) => {
// 添加token
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 添加請(qǐng)求時(shí)間戳,防止緩存
if (config.method?.toLowerCase() === 'get') {
config.params = {
...config.params,
_t: Date.now()
}
}
return config
},
(error) => {
console.error('請(qǐng)求錯(cuò)誤:', error)
return Promise.reject(error)
}
)
// 響應(yīng)攔截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const { data, config } = response
// 處理業(yè)務(wù)錯(cuò)誤
if (data.code !== 0 && data.code !== 200) {
const errorMsg = data.message || '請(qǐng)求失敗'
// 401: 未授權(quán),跳轉(zhuǎn)登錄
if (data.code === 401) {
ElMessageBox.alert('登錄已過期,請(qǐng)重新登錄', '提示', {
confirmButtonText: '重新登錄',
callback: () => {
localStorage.removeItem('token')
router.push('/login')
}
})
return Promise.reject(new Error(errorMsg))
}
// 403: 權(quán)限不足
if (data.code === 403) {
ElMessage.error('權(quán)限不足')
return Promise.reject(new Error(errorMsg))
}
// 其他錯(cuò)誤
ElMessage.error(errorMsg)
return Promise.reject(new Error(errorMsg))
}
return data.data || data
},
(error) => {
const { response, config } = error
let errorMessage = '網(wǎng)絡(luò)錯(cuò)誤,請(qǐng)檢查網(wǎng)絡(luò)連接'
if (response) {
switch (response.status) {
case 400:
errorMessage = '請(qǐng)求參數(shù)錯(cuò)誤'
break
case 401:
errorMessage = '未授權(quán),請(qǐng)重新登錄'
localStorage.removeItem('token')
router.push('/login')
break
case 403:
errorMessage = '拒絕訪問'
break
case 404:
errorMessage = '請(qǐng)求地址不存在'
break
case 500:
errorMessage = '服務(wù)器內(nèi)部錯(cuò)誤'
break
case 502:
errorMessage = '網(wǎng)關(guān)錯(cuò)誤'
break
case 503:
errorMessage = '服務(wù)不可用'
break
case 504:
errorMessage = '網(wǎng)關(guān)超時(shí)'
break
default:
errorMessage = `請(qǐng)求失敗: ${response.status}`
}
} else if (error.code === 'ECONNABORTED') {
errorMessage = '請(qǐng)求超時(shí),請(qǐng)檢查網(wǎng)絡(luò)連接'
} else if (error.message === 'Network Error') {
errorMessage = '網(wǎng)絡(luò)錯(cuò)誤,請(qǐng)檢查網(wǎng)絡(luò)連接'
}
// 不顯示GET請(qǐng)求的錯(cuò)誤(避免頻繁提示)
if (config.method?.toLowerCase() !== 'get') {
ElMessage.error(errorMessage)
}
return Promise.reject(error)
}
)
return service
}
// 創(chuàng)建服務(wù)實(shí)例
const service = createService()
// 泛型響應(yīng)類型
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
// 請(qǐng)求方法封裝
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錯(cuò)誤:', error)
this.emit('error', error)
}
this.ws.onclose = () => {
console.log('WebSocket連接關(guān)閉')
this.stopHeartbeat()
this.emit('close')
this.reconnect()
}
}
private reconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('WebSocket重連次數(shù)已達(dá)上限')
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
六、性能優(yōu)化策略
6.1 渲染性能優(yōu)化
// 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
// 滾動(dòng)位置
const scrollTop = ref(0)
// 可視區(qū)域高度
const visibleHeight = ref(containerHeight)
// 計(jì)算總高度
const totalHeight = computed(() => items.length * itemHeight)
// 計(jì)算可見項(xiàng)目的起始和結(jié)束索引
const visibleRange = computed(() => {
const start = Math.floor(scrollTop.value / itemHeight)
const visibleCount = Math.ceil(visibleHeight.value / itemHeight)
const end = start + visibleCount
// 添加緩沖區(qū)域
const bufferStart = Math.max(0, start - buffer)
const bufferEnd = Math.min(items.length, end + buffer)
return {
start: bufferStart,
end: bufferEnd,
offset: bufferStart * itemHeight
}
})
// 可見項(xiàng)目
const visibleItems = computed(() => {
const { start, end } = visibleRange.value
return items.slice(start, end)
})
// 處理滾動(dòng)
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
}
}
// 監(jiān)聽窗口大小變化
const handleResize = throttle(() => {
updateContainerHeight()
}, 200)
// 初始化
onMounted(() => {
updateContainerHeight()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
return {
scrollTop,
totalHeight,
visibleRange,
visibleItems,
handleScroll
}
}
6.2 圖表性能優(yōu)化
// 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%可見性閾值
// 檢查元素是否在可視區(qū)域
const checkVisibility = (element: HTMLElement): boolean => {
const rect = element.getBoundingClientRect()
const windowHeight = window.innerHeight || document.documentElement.clientHeight
// 計(jì)算可見比例
const visibleHeight = Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0)
const elementHeight = rect.height
return visibleHeight / elementHeight > visibilityThreshold
}
// 監(jiān)聽圖表可見性
const observeChartVisibility = (chartElement: HTMLElement, callback: (visible: boolean) => void) => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
callback(entry.isIntersecting)
})
},
{
threshold: visibilityThreshold,
rootMargin: '50px' // 預(yù)加載區(qū)域
}
)
observer.observe(chartElement)
return observer
}
// 節(jié)流渲染
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)
}
}
}
// 數(shù)據(jù)采樣(用于大數(shù)據(jù)集)
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
}
// 內(nèi)存清理
const cleanupChart = (chartInstance: any) => {
if (chartInstance) {
chartInstance.dispose()
// 強(qiáng)制垃圾回收提示
if (window.gc) {
window.gc()
}
}
}
return {
isVisible,
checkVisibility,
observeChartVisibility,
throttleRender,
sampleData,
cleanupChart
}
}
6.3 內(nèi)存管理
// 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
}
// 設(shè)置緩存
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())
// 按時(shí)間戳排序
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)
}
}
}
// 啟動(dòng)清理定時(shí)器
private startCleanupTimer(): void {
setInterval(() => {
this.cleanupExpired()
}, this.cleanupInterval)
}
// 清空所有緩存
clear(): void {
this.cache.clear()
}
// 獲取緩存統(tǒng)計(jì)信息
getStats(): {
size: number
maxSize: number
hitRate: number
} {
// 這里可以添加命中率統(tǒng)計(jì)邏輯
return {
size: this.cache.size,
maxSize: this.maxCacheSize,
hitRate: 0
}
}
}
七、動(dòng)畫與交互優(yōu)化
7.1 GSAP動(dòng)畫集成
// composables/useAnimation.ts
import { ref, onMounted, onUnmounted } from 'vue'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { TextPlugin } from 'gsap/TextPlugin'
// 注冊(cè)GSAP插件
gsap.registerPlugin(ScrollTrigger, TextPlugin)
export function useAnimation() {
const timeline = ref<gsap.core.Timeline | null>(null)
// 數(shù)字計(jì)數(shù)動(dòng)畫
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'
})
}
// 文字打字機(jī)效果
const typewriter = (
element: HTMLElement,
text: string,
duration: number = 1
): gsap.core.Tween => {
return gsap.to(element, {
duration,
text: text,
ease: 'none'
})
}
// 卡片入場(chǎng)動(dòng)畫
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)'
})
}
// 圖表渲染動(dòng)畫
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)'
}
)
}
// 創(chuàng)建時(shí)間線
const createTimeline = (): gsap.core.Timeline => {
const tl = gsap.timeline()
timeline.value = tl
return tl
}
// 滾動(dòng)觸發(fā)動(dòng)畫
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
})
}
// 粒子動(dòng)畫
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'
})
}
}
// 清理動(dòng)畫
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 交互反饋優(yōu)化
<!-- 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>
<!-- 點(diǎn)擊漣漪效果 -->
<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
// 鼠標(biāo)進(jìn)入
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
})
}
}
// 鼠標(biāo)離開
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
})
}
}
// 鼠標(biāo)按下
const handleMouseDown = (event: MouseEvent) => {
isActive.value = true
if (cardRef.value) {
gsap.to(cardRef.value, {
scale: props.activeScale,
duration: 0.1
})
}
// 創(chuàng)建漣漪效果
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)
// 動(dòng)畫效果
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)
}
}
})
}
})
}
}
// 鼠標(biāo)釋放
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>
八、部署與監(jiān)控
8.1 部署配置
# 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;
# 靜態(tài)資源緩存
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;
}
# 主應(yīng)用
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;
}
# 錯(cuò)誤頁(yè)面
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
}
8.2 性能監(jiān)控
// utils/performanceMonitor.ts
export class PerformanceMonitor {
private metrics: Map<string, any[]> = new Map()
private isMonitoring = false
// 開始監(jiān)控
start(): void {
if (this.isMonitoring) return
this.isMonitoring = true
this.setupPerformanceObserver()
this.setupErrorTracking()
this.setupResourceTiming()
}
// 停止監(jiān)控
stop(): void {
this.isMonitoring = false
}
// 設(shè)置性能觀察者
private setupPerformanceObserver(): void {
// 監(jiān)控長(zhǎng)任務(wù)
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'] })
}
// 監(jiān)控繪制時(shí)間
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)
}
}
// 監(jiān)控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'] })
// 監(jiān)聽頁(yè)面加載完成
window.addEventListener('load', () => {
measureFP()
// 監(jiān)控其他指標(biāo)
setTimeout(() => {
this.measurePerformance()
}, 1000)
})
}
// 測(cè)量性能指標(biāo)
private measurePerformance(): void {
// FPS監(jiān)控
this.monitorFPS()
// 內(nèi)存使用監(jiān)控
this.monitorMemory()
// 布局偏移監(jiān)控
this.monitorLayoutShift()
}
// 監(jiān)控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)
}
// 監(jiān)控內(nèi)存使用
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)
}
}
// 監(jiān)控布局偏移
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'] })
}
// 錯(cuò)誤追蹤
private setupErrorTracking(): void {
// JavaScript錯(cuò)誤
window.addEventListener('error', (event) => {
this.recordMetric('error', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error?.stack
})
})
// Promise錯(cuò)誤
window.addEventListener('unhandledrejection', (event) => {
this.recordMetric('promise-error', {
reason: event.reason?.message || event.reason,
stack: event.reason?.stack
})
})
// 資源加載錯(cuò)誤
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)
}
// 資源計(jì)時(shí)
private setupResourceTiming(): void {
// 監(jiān)控所有資源的加載時(shí)間
const resources = performance.getEntriesByType('resource')
resources.forEach(resource => {
this.recordMetric('resource-timing', {
name: resource.name,
duration: resource.duration,
initiatorType: resource.initiatorType,
transferSize: resource.transferSize
})
})
}
// 記錄指標(biāo)
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()
})
// 限制存儲(chǔ)數(shù)量
if (metric.length > 1000) {
metric.shift()
}
// 觸發(fā)告警
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%內(nèi)存使用
'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)
}
}
// 觸發(fā)告警
private triggerAlert(name: string, value: any, config: any): void {
console.warn(`性能告警: ${name} = ${value} ${config.type === 'low' ? '低于' : '高于'}閾值`)
// 這里可以集成到監(jiān)控系統(tǒng)
// 例如:發(fā)送到服務(wù)器、顯示通知等
}
// 獲取性能報(bào)告
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
}
// 清理數(shù)據(jù)
clear(): void {
this.metrics.clear()
}
}
九、最佳實(shí)踐與優(yōu)化建議
9.1 開發(fā)最佳實(shí)踐
組件設(shè)計(jì)原則
- 單一職責(zé)原則:每個(gè)組件只負(fù)責(zé)一個(gè)功能
- 可復(fù)用性:提取通用組件和邏輯
- 可維護(hù)性:清晰的命名和結(jié)構(gòu)
代碼規(guī)范
- 使用TypeScript保證類型安全
- 遵循ESLint規(guī)范
- 統(tǒng)一的命名約定
性能優(yōu)化
- 按需加載組件
- 合理使用緩存
- 避免不必要的重新渲染
9.2 常見問題解決方案
內(nèi)存泄漏
// 使用WeakMap避免內(nèi)存泄漏
const weakMap = new WeakMap<Element, any>()
// 及時(shí)清理定時(shí)器
const timer = setInterval(() => {}, 1000)
onUnmounted(() => clearInterval(timer))
// 移除事件監(jiān)聽器
const handleResize = () => {}
window.addEventListener('resize', handleResize)
onUnmounted(() => window.removeEventListener('resize', handleResize))
大屏適配
// 多種適配方案結(jié)合 // 1. 縮放方案:適合固定分辨率 // 2. 響應(yīng)式方案:適合多分辨率 // 3. 混合方案:根據(jù)場(chǎng)景選擇
數(shù)據(jù)更新策略
// 增量更新
const updateDataIncrementally = (newData: any[]) => {
// 只更新變化的數(shù)據(jù)
// 避免全量刷新
}
// 節(jié)流更新
const throttledUpdate = throttle(updateData, 1000)
// 批量更新
const batchUpdate = () => {
// 收集多次更新,一次性應(yīng)用
}
9.3 測(cè)試策略
單元測(cè)試
// 使用Vitest進(jìn)行單元測(cè)試
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)
})
})
性能測(cè)試
// 使用Lighthouse進(jìn)行性能測(cè)試
// 自定義性能測(cè)試腳本
const runPerformanceTest = async () => {
const metrics = await getPerformanceMetrics()
assert(metrics.fps > 30, 'FPS應(yīng)大于30')
}
集成測(cè)試
// 使用Cypress進(jìn)行端到端測(cè)試
describe('Dashboard', () => {
it('should load all charts', () => {
cy.visit('/dashboard')
cy.get('.chart-container').should('have.length', 6)
})
})
Vue大屏開發(fā)是一個(gè)系統(tǒng)工程,需要綜合考慮技術(shù)選型、架構(gòu)設(shè)計(jì)、性能優(yōu)化、用戶體驗(yàn)等多個(gè)方面。
以上就是Vue進(jìn)行大屏開發(fā)的全流程及技術(shù)細(xì)節(jié)詳解的詳細(xì)內(nèi)容,更多關(guān)于Vue大屏開發(fā)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
vue實(shí)現(xiàn)無(wú)縫滾動(dòng)的示例詳解
這篇文章主要為大家詳細(xì)介紹了vue非組件如何實(shí)現(xiàn)列表的無(wú)縫滾動(dòng)效果,文中的示例代碼簡(jiǎn)潔易懂,感興趣的小伙伴可以跟隨小編一起學(xué)習(xí)一下2023-09-09
Vue-scoped(局部)樣式使用方法及實(shí)例代碼
這篇文章主要介紹了Vue-scoped(局部)樣式使用方法及實(shí)例代碼,文中示例代碼介紹了的非常詳細(xì)感興趣的同學(xué)可以參考閱讀一下2023-05-05
vue實(shí)現(xiàn)手機(jī)號(hào)碼抽獎(jiǎng)上下滾動(dòng)動(dòng)畫示例
本篇文章主要介紹了vue實(shí)現(xiàn)手機(jī)號(hào)碼抽獎(jiǎng)上下滾動(dòng)動(dòng)畫示例。具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-10-10
vue等待數(shù)據(jù)渲染完成后執(zhí)行下一個(gè)方法問題
這篇文章主要介紹了vue等待數(shù)據(jù)渲染完成后執(zhí)行下一個(gè)方法問題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-12-12
vue2.x中的provide和inject用法小結(jié)
這篇文章主要介紹了vue2.x中的provide和inject用法小結(jié),本文通過示例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友參考下吧2023-12-12
vue click.stop阻止點(diǎn)擊事件繼續(xù)傳播的方法
今天小編就為大家分享一篇vue click.stop阻止點(diǎn)擊事件繼續(xù)傳播的方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2018-09-09
vue短信驗(yàn)證性能優(yōu)化如何寫入localstorage中
這篇文章主要介紹了vue短信驗(yàn)證性能優(yōu)化寫入localstorage中的方法,解決這個(gè)問題需要把時(shí)間都寫到localstorage里面去,具體解決方法大家參考下本文2018-04-04
vue實(shí)現(xiàn)給某個(gè)數(shù)據(jù)字段添加顏色
這篇文章主要介紹了vue實(shí)現(xiàn)給某個(gè)數(shù)據(jù)字段添加顏色方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2024-03-03

