普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月6日首页

Three.js 工业 3D 可视化:生产线状态监控系统实现方案

作者 胖虎265
2025年11月6日 17:41

在工业数字化转型过程中,3D 可视化监控系统凭借直观、沉浸式的优势,成为车间管理的重要工具。本文将详细介绍如何使用 Three.js 构建一套生产线 3D 状态监控系统,实现设备状态展示、产能数据可视化、交互式操作等核心功能

联想截图_20251106173926.jpg

一、项目背景与技术选型

1. 项目需求

  • 3D 可视化展示生产线布局及设备状态
  • 实时显示生产线运行参数(产能、产量、状态等)
  • 支持多生产线切换查看
  • 设备状态可视化(运行 / 维护 / 停机)
  • 交互式操作(视角旋转)

2. 技术栈选型

  • 3D 核心库:Three.js(Web 端 3D 图形渲染引擎)

  • 辅助库

    • GLTFLoader(3D 模型加载)
    • OrbitControls(相机控制)
    • CSS3DRenderer/CSS2DRenderer(3D/2D 标签渲染)
  • UI 框架:Element UI(进度条、样式组件)

  • 动画库:animate-number(数值动画)

  • 样式预处理:SCSS(样式模块化管理)

二、核心功能实现

1. 3D 场景基础搭建

场景初始化是 Three.js 项目的基础,需要完成场景、相机、渲染器三大核心对象的创建。

init() {
  // 1. 创建场景
  this.scene = new THREE.Scene();

  // 2. 创建网格模型(生产线底座)
  const geometry = new THREE.BoxGeometry(640, 1, 70);
  const material = new THREE.MeshLambertMaterial({
    color: 0xffffff,
    transparent: true,
    opacity: 1
  });
  this.mesh = new THREE.Mesh(geometry, material);
  this.mesh.position.set(0, -140, 0);
  this.scene.add(this.mesh);

  // 3. 光源设置(点光源+环境光)
  const pointLight = new THREE.PointLight(0xffffff, 0.5);
  pointLight.position.set(0, 200, 300);
  this.scene.add(pointLight);
  
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.3);
  this.scene.add(ambientLight);

  // 4. 相机设置(正交相机,适合工业场景展示)
  const container = document.getElementById("container");
  const width = container.clientWidth;
  const height = container.clientHeight;
  const aspectRatio = width / height;
  const scale = 230; // 场景显示范围系数

  this.camera = new THREE.OrthographicCamera(
    -scale * aspectRatio,
    scale * aspectRatio,
    scale,
    -scale,
    1,
    1000
  );
  this.camera.position.set(-100, 100, 500);
  this.camera.lookAt(this.scene.position);

  // 5. 渲染器设置
  this.renderer = new THREE.WebGLRenderer({
    antialias: true, // 抗锯齿
    preserveDrawingBuffer: true // 保留绘制缓存
  });
  this.renderer.setSize(width, height);
  this.renderer.setClearColor(0xffffff, 0); // 透明背景
  container.appendChild(this.renderer.domElement);

  // 6. 控制器设置(支持鼠标交互)
  this.controls = new OrbitControls(this.camera, this.renderer.domElement);
  this.controls.addEventListener("change", () => {
    this.renderer.render(this.scene, this.camera);
  });

  // 初始渲染
  this.renderer.render(this.scene, this.camera);
}

2. 3D 模型加载与生产线构建

(1)外部模型加载

使用 GLTFLoader 加载生产线设备 3D 模型(glb 格式),并设置模型位置:

loadGltf() {
  const loader = new GLTFLoader();
  loader.load("../model/cj.glb", (gltf) => {
    gltf.scene.position.set(16, -139, 140); // 调整模型位置适配场景
    this.scene.add(gltf.scene);
    this.renderer.render(this.scene, this.camera);
  });
}

(2)生产线围墙构建

通过 BufferGeometry 自定义几何体,创建生产线边界围墙,并用纹理贴图美化:

addWall() {
  // 围墙顶点坐标
  const vertices = [-320, 35, 320, 35, 320, -35, -320, -35, -320, 35];
  const geometry = new THREE.BufferGeometry();
  const posArr = [];
  const uvArr = [];
  const height = -40; // 围墙高度

  // 构建围墙三角面
  for (let i = 0; i < vertices.length - 2; i += 2) {
    // 两个三角形组成一个矩形面
    posArr.push(
      vertices[i], vertices[i+1], -140,
      vertices[i+2], vertices[i+3], -140,
      vertices[i+2], vertices[i+3], height,
      vertices[i], vertices[i+1], -140,
      vertices[i+2], vertices[i+3], height,
      vertices[i], vertices[i+1], height
    );
    // UV贴图坐标
    uvArr.push(0,0, 1,0, 1,1, 0,0, 1,1, 0,1);
  }

  // 设置几何体属性
  geometry.attributes.position = new THREE.BufferAttribute(new Float32Array(posArr), 3);
  geometry.attributes.uv = new THREE.BufferAttribute(new Float32Array(uvArr), 2);
  geometry.computeVertexNormals(); // 计算法线

  // 加载纹理并创建材质
  this.texture = new THREE.TextureLoader().load("../images/linearGradient.png");
  this.mesh = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({
    color: this.dict_color[this.progress.state],
    map: this.texture,
    transparent: true,
    side: THREE.DoubleSide, // 双面渲染
    depthTest: false
  }));
  this.mesh.rotation.x = -Math.PI * 0.5; // 旋转适配场景
  this.scene.add(this.mesh);
}

4. 状态可视化与数据面板

(1)多状态颜色映射

定义生产线三种状态(运行中 / 维护中 / 停机中)的颜色映射,实现状态可视化:

dict_color: {
  运行中: "#32e5ad", // 绿色
  维护中: "#fb8d1c", // 橙色
  停机中: "#e9473a"  // 红色
}

(2)数据面板设计

通过 CSS2DRenderer 将数据面板作为 2D 标签添加到 3D 场景中,实时展示生产线参数:

<div id="tooltip">
  <div class="title">DIP 2-1涂覆线</div>
  <div class="progress">
    <p class="state">
      <span class="icon" :style="{ backgroundColor: dict_color[progress.state] }"></span>
      {{ progress.state }}
    </p>
    <p class="value">
      <animate-number
        from="0"
        :key="progress.value"
        :to="progress.value"
        duration="2000"
        easing="easeOutQuad"
        :formatter="formatter"
      ></animate-number>
      %
    </p>
    <el-progress :percentage="progress.value" :show-text="false" :color="dict_color[progress.state]"></el-progress>
  </div>
  <ul class="infoList">
    <li v-for="(item, index) in infoList" :key="index">
      <label>{{ item.label }}:</label>
      <span>{{ item.value }}</span>
    </li>
  </ul>
</div>
addTooltip() {
  const tooltipDom = document.getElementById("tooltip");
  const tooltipObject = new CSS2DObject(tooltipDom);
  tooltipObject.position.set(0, 120, 0); // 面板在3D场景中的位置
  this.scene.add(tooltipObject);
  this.labelRenderer2D.render(this.scene, this.camera);
}

5. 多生产线切换功能

支持切换查看多条生产线状态,通过点击标签切换数据和状态颜色:

changeType(index) {
  this.typeIndex = index;
  // 根据索引切换不同生产线的状态数据
  if (index % 3 === 0) {
    this.progress = this.progress1; // 运行中
  } else if (index % 3 === 1) {
    this.progress = this.progress2; // 维护中
  } else {
    this.progress = this.progress3; // 停机中
  }
}

// 监听progress变化,更新3D模型颜色
watch: {
  progress: {
    handler() {
      this.mesh.material.color.set(this.dict_color[this.progress.state]);
      this.renderer.render(this.scene, this.camera);
    },
    deep: true
  }
}

6. 响应式适配

处理窗口大小变化,确保 3D 场景自适应调整:

onWindowResize() {
  const container = document.getElementById("container");
  const width = container.clientWidth;
  const height = container.clientHeight;

  // 更新渲染器尺寸
  this.renderer.setSize(width, height);
  this.labelRenderer.setSize(width, height);
  this.labelRenderer2D.setSize(width, height);

  // 更新相机参数
  const aspectRatio = width / height;
  const scale = 230;
  this.camera.left = -scale * aspectRatio;
  this.camera.right = scale * aspectRatio;
  this.camera.top = scale;
  this.camera.bottom = -scale;
  this.camera.updateProjectionMatrix();

  // 重新渲染
  this.renderer.render(this.scene, this.camera);
}

三、关键技术

1. 3D 与 2D 融合渲染

通过 CSS3DRenderer 和 CSS2DRenderer 实现 3D 场景与 2DUI 的无缝融合:

  • CSS2DRenderer:用于数据面板等需要始终面向相机的 2D 元素
  • CSS3DRenderer:用于生产线节点标签等需要 3D 空间定位的元素

2. 状态可视化设计

  • 颜色编码:用不同颜色区分设备状态,符合工业监控的视觉习惯
  • 动态更新:状态变化时实时更新 3D 模型颜色和数据面板
  • 图标标识:通过图标和文字结合,增强状态辨识度

3. 性能优化

  • 抗锯齿设置:提升 3D 模型显示清晰度
  • 双面渲染:确保围墙等几何体正反面都能正常显示
  • 纹理复用:减少重复纹理加载,提升性能
  • 事件监听优化:仅在必要时重新渲染场景

Vue2 项目常用配置合集:多语言、SVG 图标、代码格式化、权限指令 + 主题切换

作者 胖虎265
2025年11月6日 11:12

在 Vue2 项目开发中,合理的基础配置能显著提升开发效率、规范代码风格并增强项目可维护性。本文整理了 4 个高频实用配置:多语言(i18n)、SVG 图标组件、代码格式化(ESLint + Prettier)、权限控制指令、主题切换,附上可复用代码和配置说明,适用于大多数 Vue2 业务项目

一、多语言配置(vue-i18n)

多语言是中后台系统、国际化产品的核心需求,基于 vue-i18n 实现语言切换与文案管理,支持本地存储记忆语言偏好。

1.1 安装依赖

# Vue2 需使用 vue-i18n@8.x 版本(9.x 仅支持 Vue3)
npm install vue-i18n@8 --save

1.2 完整配置代码

1.2.1 核心配置文件(src/i18n/index.js)

import Vue from 'vue'
import VueI18n from 'vue-i18n'
import zhCN from './langs/zh-CN' // 中文文案
import enUS from './langs/en-US' // 英文文案
import { I18N_CONFIG, STORAGE_KEYS } from '@/constants' // 常量配置

// 注册 i18n 插件
Vue.use(VueI18n)

// 语言包集合(可扩展更多语言:如 ja-JP 日语、ko-KR 韩语)
const messages = {
  'zh-CN': zhCN,
  'en-US': enUS,
}

/**
 * 从本地存储获取语言设置,无则使用默认语言
 */
const getLanguage = () => {
  return localStorage.getItem(STORAGE_KEYS.LANGUAGE) || I18N_CONFIG.defaultLocale
}

// 创建 i18n 实例
const i18n = new VueI18n({
  locale: getLanguage(), // 当前语言(优先本地存储)
  messages, // 语言包
  silentTranslationWarn: true, // 关闭「未找到翻译」警告(避免控制台冗余)
  silentFallbackWarn: true, // 关闭「回退语言」警告
  fallbackLocale: 'zh-CN', // 翻译缺失时的回退语言
})

export default i18n

1.2.2 英文语言包(src/i18n/langs/en-US.js)

export default {
  route: {
    dashboard: 'Dashboard',
    system: 'System',
    user: 'User',
    role: 'Role',
    menu: 'Menu',
    monitor: 'Monitor',
    job: 'Job',
    login: 'Login',
    error: 'Error',
    404: '404',
    401: '401',
    profile: 'Profile',
   }
}

1.2.3 中文语言包(src/i18n/langs/zh-CN.js)

export default {
  route: {
    dashboard: '首页',
    system: '系统管理',
    user: '用户管理',
    role: '角色管理',
    menu: '菜单管理',
    monitor: '系统监控',
    job: '定时任务',
    login: '登录',
    error: '错误页面',
    404: '404',
    401: '401',
    profile: '个人中心',
  }
}

1.3 配套常量与使用方式

  • 常量配置(src/constants/index.js):定义默认语言、存储键名
  • 语言包示例:按模块划分文案(如 common、user、menu)
  • 全局注册:在 main.js 注入 i18n 实例
  • 使用方式:模板中 {{ $t('common.confirm') }},脚本中 this.$t('common.success')

二、SVG 图标组件(svg-sprite-loader)

使用 SVG 图标比图片图标更清晰、可缩放,配合 svg-sprite-loader 实现按需加载,封装通用 SvgIcon 组件。

2.1 安装依赖

npm install svg-sprite-loader --save-dev

2.2 Webpack 配置(vue.config.js)

const path = require('path')

// 路径解析辅助函数
function resolve(dir) {
  return path.join(__dirname, dir)
}

module.exports = {
  chainWebpack: config => {
    // 1. 清除默认的 svg 处理规则(避免与 svg-sprite-loader 冲突)
    config.module.rules.delete('svg')
    
    // 2. 配置 svg-sprite-loader 处理 src/icons 目录下的 SVG
    config.module
      .rule('icons')
      .test(/.svg$/) // 匹配 svg 文件
      .include.add(resolve('src/icons')) // 只处理 src/icons 目录
      .end()
      .use('svg-sprite-loader')
      .loader('svg-sprite-loader')
      .options({
        symbolId: 'icon-[name]' // 生成的 symbol id 格式:icon-文件名
      })
      .end()
  }
}

2.3 通用 SVG 组件(src/components/SvgIcon/index.vue)

<template>
  <!-- 外部 SVG 图标(http/https 链接) -->
  <div
    v-if="isExternal"
    :style="styleExternalIcon"
    class="svg-external-icon svg-icon"
    v-on="$listeners"
  />
  <!-- 内部 SVG 图标(src/icons 目录下) -->
  <svg v-else :class="svgClass" aria-hidden="true" v-on="$listeners">
    <use :xlink:href="iconName" /> <!-- 关联 svg-sprite 的 symbol id -->
  </svg>
</template>

<script>
export default {
  name: 'SvgIcon', // 组件名(必须,便于注册和调试)
  props: {
    iconClass: {
      type: String,
      required: true, // 图标名称(必填)
    },
    className: {
      type: String,
      default: '', // 额外类名(用于自定义样式)
    },
  },
  computed: {
    // 判断是否为外部 SVG 图标(http/https/mailto/tel 开头)
    isExternal() {
      return /^(https?:|mailto:|tel:)/.test(this.iconClass)
    },
    // 内部图标:拼接 symbol id(与 webpack 配置的 symbolId 一致)
    iconName() {
      return `#icon-${this.iconClass}`
    },
    // 拼接最终的类名
    svgClass() {
      return this.className ? `svg-icon ${this.className}` : 'svg-icon'
    },
    // 外部图标样式(通过 mask 实现 SVG 效果)
    styleExternalIcon() {
      return {
        mask: `url(${this.iconClass}) no-repeat 50% 50%`,
        '-webkit-mask': `url(${this.iconClass}) no-repeat 50% 50%`,
        maskSize: 'cover',
        '-webkit-mask-size': 'cover'
      }
    },
  },
}
</script>

<style scoped>
/* 基础 SVG 样式:继承父元素颜色(fill: currentColor) */
.svg-icon {
  width: 1em;
  height: 1em;
  fill: currentColor;
  overflow: hidden;
  vertical-align: middle; /* 对齐文字 */
}

/* 外部 SVG 图标样式 */
.svg-external-icon {
  background-color: currentColor;
  display: inline-block;
}
</style>

2.4 自动导入与使用

import Vue from 'vue'
import SvgIcon from '@/components/SvgIcon'

// 注册全局组件
Vue.component('svg-icon', SvgIcon)

// 自动导入所有 SVG 图标
const req = require.context('./svg', false, /\.svg$/)
const requireAll = requireContext => {
  const files = requireContext.keys()
  files.forEach(key => {
    const name = key.replace(/^\.\/(.*)\.\w+$/, '$1')
    const component = requireContext(key)
  })
  return files.map(requireContext)
}
requireAll(req)

  • 图标自动导入(src/icons/index.js):批量导入目录下所有 SVG
  • 全局注册:在 main.js 注册 SvgIcon 组件
  • 使用方式:<svg-icon icon-class="user" className="text-red-500" />

三、代码格式化配置(ESLint + Prettier)

统一代码风格,减少团队协作冲突,自动修复格式问题。

3.1 安装依赖

npm install eslint prettier eslint-plugin-vue eslint-config-prettier eslint-plugin-prettier @babel/eslint-parser --save-dev

3.2 核心配置文件

3.2.1 Prettier 配置(.prettierrc)

{
  "semi": false, // 不加分号
  "singleQuote": true, // 使用单引号
  "printWidth": 100, // 每行最大长度(超过自动换行)
  "tabWidth": 2, // 缩进 2 个空格
  "useTabs": false, // 不使用制表符(Tab)
  "trailingComma": "es5", // 对象/数组末尾加逗号(ES5 兼容)
  "bracketSpacing": true, // 对象字面量前后加空格({ foo: bar })
  "arrowParens": "avoid", // 箭头函数参数只有一个时不加括号(x => x)
  "vueIndentScriptAndStyle": true, // Vue 组件中 script 和 style 缩进与 template 一致
  "htmlWhitespaceSensitivity": "ignore" // HTML 空格不敏感
}

3.2.2 ESLint 配置(.eslintrc.js)

module.exports = {
  root: true, // 标识为根配置
  env: {
    node: true,
    jest: true,
    browser: true
  },
  extends: [
    'plugin:vue/essential',
    'eslint:recommended',
    'plugin:prettier/recommended'
  ],
  parserOptions: {
    parser: '@babel/eslint-parser',
    requireConfigFile: false,
    ecmaVersion: 2020,
    sourceType: 'module'
  },
  rules: {
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-unused-vars': [
      'warn',
      {
        vars: 'all',
        args: 'after-used',
        ignoreRestSiblings: true,
        argsIgnorePattern: '^_',
        varsIgnorePattern: '^_'
      }
    ]
  }
}

3.3 脚本与使用

  • 配置脚本(package.json):添加 lint 和 lint:fix 命令
  • 忽略文件:.prettierignore + .eslintignore 排除无需格式化的文件
  • 使用方式:npm run lint 检查格式,npm run lint:fix 自动修复

四、权限控制指令(v-permission)

基于用户权限动态显示 / 隐藏 DOM 元素,适用于按钮、菜单等权限控制场景。

4.1 指令实现(src/directives/permission.js)

import store from '@/store' // Vuex 存储用户权限信息

export default {
  // 指令绑定到元素时执行
  inserted(el, binding) {
    checkPermission(el, binding)
  },
  // 指令所在组件更新时执行(如权限变化后)
  update(el, binding) {
    checkPermission(el, binding)
  },
}

/**
 * 权限校验核心逻辑
 * @param el 指令绑定的 DOM 元素
 * @param binding 指令绑定信息(value 为权限标识数组)
 */
function checkPermission(el, binding) {
  const { value } = binding

  // 1. 校验权限标识格式(必须是数组)
  if (!value || !(value instanceof Array) || value.length === 0) {
    console.warn("权限指令使用错误!需指定权限标识数组,例如:v-permission="['admin','editor']"")
    el.parentNode?.removeChild(el) // 格式错误时隐藏元素
    return
  }

  // 2. 获取用户权限和角色(从 Vuex 中获取)
  const userPermissions = store.getters['user/permissions'] // 权限列表(如 ['user:list'])
  const userRoles = store.getters['user/roles'] // 角色列表(如 ['admin'])

  // 3. 权限判断:拥有任一所需权限 或 是管理员 → 显示元素
  const hasPermission = userPermissions.some(perm => value.includes(perm)) || userRoles.includes('admin')

  // 4. 无权限时移除元素
  if (!hasPermission) {
    el.parentNode?.removeChild(el)
  }
}

4.2 全局注册与使用

  • 注册指令(src/directives/index.js):全局注册 v-permission
  • 导入指令:在 main.js 引入
  • 使用方式:<el-button v-permission="['admin','user:edit']">编辑</el-button>

五、主题色配置(支持多主题切换)

基于 CSS 变量 + SCSS 实现主题切换,适配 Element UI 组件样式,支持默认、暗黑、蓝色三种主题,可扩展自定义主题。

5.1 主题配置常量(src/constants/theme.js)

// 主题配置:定义各主题的颜色变量
export const themes = {
  default: {
    // 主色调
    primaryColor: '#409EFF',
    // 功能色
    successColor: '#67C23A',
    warningColor: '#E6A23C',
    dangerColor: '#F56C6C',
    infoColor: '#909399',
    // 基础色
    textColor: '#303133',
    borderColor: '#DCDFE6',
    backgroundColor: '#FFFFFF',
    // 布局色
    sidebarBgColor: '#ffffff',
    sidebarTextColor: '#303133',
    sidebarActiveTextColor: '#409EFF',
  },
  dark: {
    primaryColor: '#409EFF',
    successColor: '#67C23A',
    warningColor: '#E6A23C',
    dangerColor: '#F56C6C',
    infoColor: '#909399',
    textColor: '#E5EAF3',
    borderColor: '#4C4D4F',
    backgroundColor: '#141414',
    sidebarBgColor: '#1f1f1f',
    sidebarTextColor: '#bfcbd9',
    sidebarActiveTextColor: '#409EFF',
  },
  blue: {
    primaryColor: '#1890ff',
    successColor: '#52c41a',
    warningColor: '#faad14',
    dangerColor: '#f5222d',
    infoColor: '#909399',
    textColor: '#303133',
    borderColor: '#DCDFE6',
    backgroundColor: '#FFFFFF',
    sidebarBgColor: '#001529',
    sidebarTextColor: '#bfcbd9',
    sidebarActiveTextColor: '#1890ff',
  },
}

// 主题列表(用于下拉选择框)
export const themeList = [
  { label: '默认主题', value: 'default' },
  { label: '暗黑主题', value: 'dark' },
  { label: '蓝色主题', value: 'blue' }
]

// 从本地存储获取当前主题(无则默认)
export function getTheme() {
  return localStorage.getItem('theme') || 'default'
}

// 设置并应用主题
export function setTheme(theme) {
  localStorage.setItem('theme', theme)
  applyTheme(theme)
}

// 应用主题核心逻辑:设置 CSS 变量 + 切换 body 类名
export function applyTheme(theme) {
  const themeConfig = themes[theme]
  if (!themeConfig) return

  // 1. 移除所有主题类,添加当前主题类
  document.body.classList.remove('theme-default', 'theme-dark', 'theme-blue')
  document.body.classList.add(`theme-${theme}`)

  // 2. 设置全局 CSS 变量(供组件使用)
  Object.keys(themeConfig).forEach(key => {
    document.documentElement.style.setProperty(`--${key}`, themeConfig[key])
  })
}

5.2 SCSS 主题样式(src/styles/theme.scss)

@use './variables.scss' as *;
@use './mixin.scss' as *;

// 全局 CSS 变量(默认值)
:root {
  --primaryColor: #409eff;
  --successColor: #67c23a;
  --warningColor: #e6a23c;
  --dangerColor: #f56c6c;
  --infoColor: #909399;
  --textColor: #303133;
  --borderColor: #dcdfe6;
  --backgroundColor: #f5f7fa;
  --sidebarBgColor: #304156;
  --sidebarTextColor: #bfcbd9;
  --sidebarActiveTextColor: #409eff;
}

// -------------------------- 主题专属样式 --------------------------
// 默认主题
.theme-default {
  // 按钮样式
  .el-button--primary {
    background-color: #409eff;
    border-color: #409eff;

    &:hover {
      background-color: #66b1ff;
      border-color: #66b1ff;
    }

    &:active {
      background-color: #3a8ee6;
      border-color: #3a8ee6;
    }
  }

  // 表格样式
  .el-table {
    background-color: #fff;

    th {
      background-color: #f5f7fa;
      color: #303133;
      font-weight: 500;
    }

    td {
      color: #606266;
    }

    tr:hover > td {
      background-color: rgba(64, 158, 255, 0.05) !important;
    }

    .el-table__row--striped {
      background-color: #fafafa;
    }
  }
}

// 暗黑主题(重点适配 Element UI 组件)
.theme-dark {
  // 卡片样式
  .el-card {
    background-color: #1f1f1f;
    border-color: #4c4d4f;
    color: #a8abb2;

    .el-card__header {
      border-bottom-color: #4c4d4f;
      color: #e5eaf3;
    }
  }

  // 表格样式
  .el-table {
    background-color: #1f1f1f;
    color: #e5eaf3;

    th {
      background-color: rgba(#141414, 0.6) !important;
      color: #e5eaf3;
    }

    td {
      border-bottom: 1px solid rgba(#4c4d4f, 0.1);
      color: #a8abb2;
    }

    tr:hover > td {
      background-color: rgba(255, 255, 255, 0.05) !important;
    }
  }

  // 输入框样式
  .el-input__inner {
    background-color: #1f1f1f;
    border-color: #4c4d4f;
    color: #a8abb2;

    &:hover {
      border-color: #606266;
    }

    &:focus {
      border-color: #409eff;
    }
  }

  // 对话框、下拉框、提示框等组件样式(完整代码见上文)
}

// 蓝色主题
.theme-blue {
  .el-button--primary {
    background-color: #1890ff;
    border-color: #1890ff;

    &:hover {
      background-color: #40a9ff;
      border-color: #40a9ff;
    }
  }

  .el-menu-item.is-active {
    background-color: rgba(24, 144, 255, 0.1) !important;
  }
}

// -------------------------- 全局通用样式 --------------------------
body {
  color: var(--textColor);
  background-color: var(--backgroundColor);
  transition: background-color 0.3s ease; // 平滑过渡
}

// 侧边栏样式
.sidebar-container {
  background-color: var(--sidebarBgColor);

  .el-menu {
    background-color: var(--sidebarBgColor);

    .el-menu-item,
    .el-submenu__title {
      color: var(--sidebarTextColor);

      &.is-active {
        color: var(--sidebarActiveTextColor);
      }
    }
  }
}

// 按钮、表单、卡片等全局样式(使用 CSS 变量,自动适配主题)
.el-button--primary {
  background-color: var(--primaryColor);
  border-color: var(--primaryColor);
}

.el-input__inner {
  color: var(--textColor);
  background-color: var(--backgroundColor);
  border-color: var(--borderColor);
}

.el-card {
  background-color: var(--backgroundColor);
  border-color: var(--borderColor);
}

5.3 主题初始化与切换

5.3.1 初始化主题(main.js)

import Vue from 'vue'
import App from './App.vue'
import { getTheme, applyTheme } from '@/constants/theme'
import '@/styles/theme.scss' // 导入主题样式

// 初始化主题(页面加载时执行)
applyTheme(getTheme())

new Vue({
  el: '#app',
  render: h => h(App)
})

5.3.2 主题切换组件(示例)

<template>
  <el-select 
    v-model="currentTheme" 
    placeholder="选择主题" 
    size="mini"
    @change="handleThemeChange"
  >
    <el-option 
      v-for="item in themeList" 
      :key="item.value" 
      :label="item.label" 
      :value="item.value"
    ></el-option>
  </el-select>
</template>

<script>
import { getTheme, setTheme, themeList } from '@/constants/theme'

export default {
  data() {
    return {
      themeList,
      currentTheme: getTheme()
    }
  },
  methods: {
    handleThemeChange(theme) {
      setTheme(theme) // 切换并保存主题
      this.currentTheme = theme
      this.$message.success(`已切换至${themeList.find(item => item.value === theme).label}`)
    }
  }
}
</script>

5.4 主题适配说明

  1. CSS 变量优先级:主题切换时通过 document.documentElement.style.setProperty 动态修改 CSS 变量,覆盖默认值;
  2. Element UI 适配:通过主题类(如 .theme-dark)针对性修改 Element 组件样式,解决第三方组件主题兼容问题;
  3. 平滑过渡:在 body 或核心容器添加 transition: background-color 0.3s ease,实现主题切换时的视觉过渡;
  4. 扩展性:新增主题时,只需在 themes 常量中添加配置,在 theme.scss 中补充专属样式即可。

前端多文件上传核心功能实现:格式支持、批量上传与状态可视化

作者 胖虎265
2025年11月6日 10:30

在文件上传类应用中,多格式兼容批量处理能力清晰的状态反馈是提升用户体验的关键。本文将聚焦这三大核心需求,基于 Vue + Element UI 实现一套可复用的文件上传解决方案,包含完整代码示例和关键逻辑解析。

image.png

一、核心功能概览

功能点 技术参数 核心价值
多格式支持 覆盖 DOC、DOCX、PDF、TXT、XLS、XLSX、CSV、PPTX、HTML、JSON、MD 共 11 种格式 满足办公、开发、文档管理等多场景需求
批量上传 单次最多 100 个文件,单个文件 ≤ 100MB 高效处理多文件上传场景,避免重复操作
状态可视化 实时展示上传进度、成功 / 失败 / 等待状态,支持暂停 / 重试 / 批量删除 让用户清晰掌握上传状态,降低操作成本

二、完整实现代码(可直接复用)

2.1 主组件(DocumentUpload.vue)

负责文件选择、上传逻辑、状态管理和交互控制:

<template>
  <div class="document-upload">
    <!-- 上传区域 -->
    <div class="upload-container card">
      <el-upload
        ref="uploadRef"
        drag
        :action="uploadApi"
        :show-file-list="false"
        multiple
        :auto-upload="false"
        :accept="acceptFormats"
        :on-change="handleFileSelect"
      >
        <div class="upload-dragger">
          <i class="el-icon-upload"></i>
          <div class="upload-text">点击上传或拖拽文档到这里</div>
          <div class="upload-tip">
            支持 {{ formatNames }},最多一次上传100个文件,每个文件不超过100MB
          </div>
        </div>
      </el-upload>

      <!-- 文件状态列表 -->
      <FileUploadStatus
        :file-list="fileList"
        @batch-delete="handleBatchDelete"
        @pause-upload="handlePauseUpload"
        @retry-upload="handleRetryUpload"
      />
    </div>
  </div>
</template>

<script>
import FileUploadStatus from './FileUploadStatus.vue'
import axios from 'axios'

export default {
  name: 'DocumentUpload',
  components: { FileUploadStatus },
  data() {
    return {
      // 支持的文件格式(核心配置)
      acceptFormats: '.doc,.docx,.pdf,.txt,.xls,.xlsx,.csv,.pptx,.html,.json,.md',
      formatNames: 'DOC、DOCX、PDF、TXT、XLS、XLSX、CSV、PPTX、HTML、JSON、MD',
      // 上传配置
      uploadApi: '/api/v1/upload/document', // 替换为实际接口地址
      fileList: [], // 所有文件列表(含状态)
      uploadQueue: [], // 上传队列
      concurrentLimit: 5, // 并发上传限制(避免服务器压力)
      currentConcurrent: 0, // 当前正在上传的文件数
      isProcessing: false // 防止重复处理选择事件
    }
  },
  methods: {
    // 1. 文件选择处理(格式+数量+大小校验)
    handleFileSelect(file, fileList) {
      // 仅处理刚选择的文件(status=ready)
      if (file.status !== 'ready' || this.isProcessing) return
      this.isProcessing = true

      setTimeout(() => {
        try {
          this.$refs.uploadRef.clearFiles() // 清除组件内置列表,统一用自定义列表管理

          // 校验1:单次选择不超过100个
          if (fileList.length > 100) {
            this.$message.error('最多一次上传100个文件')
            return
          }

          // 校验2:总文件数不超过100个
          const newFiles = fileList.filter(f => 
            !this.fileList.some(item => item.uid === f.uid) // 去重
          )
          const totalCount = this.fileList.length + newFiles.length
          if (totalCount > 100) {
            this.$message.error(`当前已选择${this.fileList.length}个,本次选择${newFiles.length}个,总数超过100个限制`)
            return
          }

          // 校验3:单个文件不超过100MB
          const invalidFiles = newFiles.filter(f => f.size > 100 * 1024 * 1024)
          if (invalidFiles.length) {
            const fileNames = invalidFiles.map(f => f.name).join('、')
            this.$message.error(`文件「${fileNames}」超过100MB限制,无法上传`)
            return
          }

          // 校验通过:添加到文件列表和上传队列
          newFiles.forEach(newFile => {
            this.fileList.unshift({
              ...newFile,
              status: 'ready', // 初始状态:等待上传
              percentage: 0,
              reason: '' // 失败原因
            })
            this.uploadQueue.push(newFile)
          })

          // 开始处理上传队列
          this.processUploadQueue()
        } finally {
          this.isProcessing = false
        }
      }, 0)
    },

    // 2. 并发上传队列处理
    processUploadQueue() {
      const availableSlots = this.concurrentLimit - this.currentConcurrent
      if (availableSlots <= 0 || this.uploadQueue.length === 0) return

      // 取出可上传的文件
      const filesToUpload = this.uploadQueue.splice(0, availableSlots)
      this.currentConcurrent += filesToUpload.length

      // 逐个上传
      filesToUpload.forEach(file => {
        this.uploadFile(file)
          .then(() => {
            this.currentConcurrent--
            this.processUploadQueue() // 递归处理下一批
          })
          .catch(() => {
            this.currentConcurrent--
            this.processUploadQueue()
          })
      })
    },

    // 3. 单个文件上传核心逻辑
    uploadFile(file) {
      return new Promise((resolve, reject) => {
        const fileIndex = this.fileList.findIndex(item => item.uid === file.uid)
        if (fileIndex === -1) return reject(new Error('文件不存在'))

        // 取消上传令牌(用于暂停功能)
        const CancelToken = axios.CancelToken
        const source = CancelToken.source()

        // 更新文件状态
        const updateFileStatus = (updates) => {
          this.fileList[fileIndex] = { ...this.fileList[fileIndex], ...updates }
        }

        // 初始化上传状态
        updateFileStatus({
          status: 'uploading',
          source // 保存取消令牌,用于暂停
        })

        // 构建表单数据
        const formData = new FormData()
        formData.append('file', file.raw)
        formData.append('fileName', file.name)
        formData.append('fileSize', file.size)

        // 发送上传请求
        axios({
          url: this.uploadApi,
          method: 'POST',
          headers: {
            'Content-Type': 'multipart/form-data',
            'token': localStorage.getItem('token') // 替换为实际权限令牌
          },
          data: formData,
          timeout: 300000, // 5分钟超时(适配大文件)
          cancelToken: source.token,
          // 实时更新上传进度
          onUploadProgress: (progressEvent) => {
            if (progressEvent.lengthComputable) {
              const percentage = Math.round((progressEvent.loaded / progressEvent.total) * 100)
              updateFileStatus({ percentage })
            }
          }
        })
          .then(res => {
            if (res.code === 200) {
              updateFileStatus({
                status: 'finished',
                percentage: 100,
                source: null
              })
              this.$message.success(`文件「${file.name}」上传成功`)
              resolve()
            } else {
              const errMsg = res.msg || '上传失败'
              updateFileStatus({
                status: 'failed',
                reason: errMsg,
                source: null
              })
              this.$message.error(`文件「${file.name}」上传失败:${errMsg}`)
              reject(new Error(errMsg))
            }
          })
          .catch(err => {
            if (axios.isCancel(err)) {
              // 手动取消(暂停)
              updateFileStatus({
                status: 'ready',
                reason: '已暂停上传',
                source: null
              })
            } else {
              // 异常失败
              const errMsg = err.message || '网络异常'
              updateFileStatus({
                status: 'failed',
                reason: errMsg,
                source: null
              })
              this.$message.error(`文件「${file.name}」上传异常:${errMsg}`)
            }
            reject(err)
          })
      })
    },

    // 4. 暂停上传
    handlePauseUpload({ file }) {
      const targetFile = this.fileList.find(item => item.uid === file.uid)
      if (targetFile && targetFile.source) {
        targetFile.source.cancel('用户暂停上传')
      }
    },

    // 5. 重试上传
    handleRetryUpload({ file }) {
      const fileIndex = this.fileList.findIndex(item => item.uid === file.uid)
      if (fileIndex !== -1) {
        // 重置状态并加入队列
        this.fileList[fileIndex] = {
          ...this.fileList[fileIndex],
          status: 'ready',
          percentage: 0,
          reason: ''
        }
        this.uploadQueue.push(file)
        this.processUploadQueue()
      }
    },

    // 6. 批量删除文件
    handleBatchDelete(selectedUids) {
      // 移除文件列表中的文件
      this.fileList = this.fileList.filter(item => !selectedUids.includes(item.uid))
      // 移除上传队列中的文件
      this.uploadQueue = this.uploadQueue.filter(file => !selectedUids.includes(file.uid))
      // 更新并发数(如果删除的是正在上传的文件)
      const uploadingCount = selectedUids.filter(uid => 
        this.fileList.some(item => item.uid === uid && item.status === 'uploading')
      ).length
      this.currentConcurrent = Math.max(0, this.currentConcurrent - uploadingCount)
      // 继续处理队列
      this.processUploadQueue()
      this.$message.success(`成功删除${selectedUids.length}个文件`)
    }
  }
}
</script>

<style scoped lang="scss">
.document-upload {
  .upload-container {
    width: 100%;
    padding: 20px;
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 2px 12px rgba(0,0,0,0.08);

    .el-upload-dragger {
      display: flex;
      flex-direction: column;
      align-items: center;
      padding: 40px 0;
      border: 2px dashed #dcdcdc;
      border-radius: 8px;
      transition: border-color 0.3s;

      &:hover {
        border-color: #409eff;
      }

      .el-icon-upload {
        font-size: 48px;
        color: #409eff;
        margin-bottom: 16px;
      }

      .upload-text {
        font-size: 16px;
        color: #333;
        margin-bottom: 8px;
      }

      .upload-tip {
        font-size: 12px;
        color: #999;
        text-align: center;
        max-width: 80%;
      }
    }
  }
}
</style>

2.2 状态展示组件(FileUploadStatus.vue)

负责文件状态可视化、筛选和操作按钮渲染:

<template>
  <div class="file-upload-status">
    <!-- 顶部筛选栏 -->
    <div class="status-header">
      <div class="file-count">已选择 {{ fileList.length }} 个文件</div>
      <div class="filter-controls">
        <el-checkbox v-model="isAllSelected" @change="handleAllSelect">全选</el-checkbox>
        <el-button 
          type="text" 
          icon="el-icon-delete" 
          class="delete-btn"
          :disabled="selectedUids.length === 0"
          @click="handleBatchDeleteConfirm"
        >
          批量删除
        </el-button>
        <!-- 状态筛选 -->
        <el-select v-model="statusFilter" placeholder="全部状态" size="mini" @change="filterFiles">
          <el-option label="全部" value=""></el-option>
          <el-option label="等待上传" value="ready"></el-option>
          <el-option label="上传中" value="uploading"></el-option>
          <el-option label="上传成功" value="finished"></el-option>
          <el-option label="上传失败" value="failed"></el-option>
        </el-select>
      </div>
    </div>

    <!-- 文件列表 -->
    <div class="file-list">
      <el-empty 
        v-if="filteredFileList.length === 0" 
        description="暂无文件" 
        :image-size="60"
      />
      <div 
        v-for="file in filteredFileList" 
        :key="file.uid"
        class="file-item"
        :class="`status-${file.status}`"
      >
        <!-- 复选框 -->
        <el-checkbox 
          v-model="selectedUids" 
          :label="file.uid"
          :disabled="file.status === 'uploading'"
        ></el-checkbox>
        <!-- 文件图标 -->
        <div class="file-icon">
          <img :src="getFileIcon(file.name)" alt="文件图标" />
        </div>
        <!-- 文件信息 -->
        <div class="file-info">
          <div class="file-name">{{ file.name }}</div>
          <div class="file-meta">
            <span class="file-size">{{ formatFileSize(file.size) }}</span>
            <span class="file-status" :title="file.reason || getStatusText(file.status)">
              {{ file.reason || getStatusText(file.status) }}
            </span>
          </div>
        </div>
        <!-- 进度条/操作按钮 -->
        <div class="file-actions">
          <!-- 上传中:进度条 + 暂停按钮 -->
          <div v-if="file.status === 'uploading'" class="uploading-controls">
            <el-progress :percentage="file.percentage" size="small" :text-inside="true"></el-progress>
            <el-button icon="el-icon-pause" size="mini" @click="emitPauseUpload(file)"></el-button>
          </div>
          <!-- 等待上传:开始按钮 -->
          <el-button 
            v-else-if="file.status === 'ready'" 
            icon="el-icon-play" 
            size="mini" 
            @click="emitRetryUpload(file)"
          ></el-button>
          <!-- 上传失败:重试按钮 -->
          <el-button 
            v-else-if="file.status === 'failed'" 
            icon="el-icon-refresh" 
            size="mini" 
            type="text"
            @click="emitRetryUpload(file)"
          ></el-button>
          <!-- 上传成功:无操作 -->
          <div v-else></div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
// 工具函数:格式化文件大小
const formatFileSize = (size) => {
  if (size < 1024) return size + 'B'
  if (size < 1024 * 1024) return (size / 1024).toFixed(1) + 'KB'
  return (size / (1024 * 1024)).toFixed(1) + 'MB'
}

export default {
  name: 'FileUploadStatus',
  props: {
    fileList: {
      type: Array,
      required: true,
      default: () => []
    }
  },
  data() {
    return {
      selectedUids: [], // 选中的文件UID
      isAllSelected: false, // 全选状态
      statusFilter: '', // 状态筛选
      filteredFileList: [] // 筛选后的文件列表
    }
  },
  watch: {
    fileList: {
      handler() {
        this.filterFiles()
        this.updateAllSelectedStatus()
      },
      deep: true
    },
    statusFilter() {
      this.filterFiles()
    },
    selectedUids() {
      this.updateAllSelectedStatus()
    }
  },
  mounted() {
    this.filterFiles()
  },
  methods: {
    formatFileSize,
    // 筛选文件
    filterFiles() {
      if (!this.statusFilter) {
        this.filteredFileList = [...this.fileList]
        return
      }
      this.filteredFileList = this.fileList.filter(file => file.status === this.statusFilter)
    },
    // 更新全选状态
    updateAllSelectedStatus() {
      const selectableFiles = this.filteredFileList.filter(file => file.status !== 'uploading')
      this.isAllSelected = selectableFiles.length > 0 && 
        this.selectedUids.length === selectableFiles.length
    },
    // 全选/取消全选
    handleAllSelect(checked) {
      if (checked) {
        this.selectedUids = this.filteredFileList
          .filter(file => file.status !== 'uploading')
          .map(file => file.uid)
      } else {
        this.selectedUids = []
      }
    },
    // 批量删除确认
    handleBatchDeleteConfirm() {
      this.$confirm('确定删除选中的文件吗?', '提示', {
        type: 'warning',
        confirmButtonText: '确定',
        cancelButtonText: '取消'
      }).then(() => {
        this.$emit('batch-delete', this.selectedUids)
        this.selectedUids = []
      }).catch(() => {})
    },
    // 获取文件图标(根据后缀名)
    getFileIcon(fileName) {
      const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase()
      const iconMap = {
        '.doc': require('@/assets/icons/file-doc.png'),
        '.docx': require('@/assets/icons/file-docx.png'),
        '.pdf': require('@/assets/icons/file-pdf.png'),
        '.txt': require('@/assets/icons/file-txt.png'),
        '.xls': require('@/assets/icons/file-xls.png'),
        '.xlsx': require('@/assets/icons/file-xlsx.png'),
        '.csv': require('@/assets/icons/file-csv.png'),
        '.pptx': require('@/assets/icons/file-pptx.png'),
        '.html': require('@/assets/icons/file-html.png'),
        '.json': require('@/assets/icons/file-json.png'),
        '.md': require('@/assets/icons/file-md.png')
      }
      return iconMap[ext] || require('@/assets/icons/file-default.png')
    },
    // 获取状态文本
    getStatusText(status) {
      const statusMap = {
        ready: '等待上传',
        uploading: '上传中',
        finished: '上传成功',
        failed: '上传失败'
      }
      return statusMap[status] || '未知状态'
    },
    // 触发暂停上传
    emitPauseUpload(file) {
      this.$emit('pause-upload', { file })
    },
    // 触发重试上传
    emitRetryUpload(file) {
      this.$emit('retry-upload', { file })
    }
  }
}
</script>

<style scoped lang="scss">
.file-upload-status {
  margin-top: 20px;

  .status-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;

    .file-count {
      font-size: 14px;
      color: #333;
    }

    .filter-controls {
      display: flex;
      align-items: center;
      gap: 12px;

      .delete-btn {
        color: #f56c6c;
      }
    }
  }

  .file-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
    max-height: 500px;
    overflow-y: auto;
    padding-right: 8px;
  }

  .file-item {
    display: flex;
    align-items: center;
    padding: 12px;
    background: #fafafa;
    border-radius: 4px;
    gap: 12px;

    &.status-uploading {
      background: #f0f7ff;
    }

    &.status-finished {
      background: #f0fff4;
    }

    &.status-failed {
      background: #fff0f0;
    }
  }

  .file-icon {
    width: 32px;
    height: 32px;

    img {
      width: 100%;
      height: 100%;
      object-fit: contain;
    }
  }

  .file-info {
    flex: 1;
    min-width: 0;

    .file-name {
      font-size: 14px;
      color: #333;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      margin-bottom: 4px;
    }

    .file-meta {
      display: flex;
      gap: 16px;
      font-size: 12px;
      color: #666;

      .file-status {
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }
    }
  }

  .file-actions {
    display: flex;
    align-items: center;
    gap: 12px;

    .uploading-controls {
      display: flex;
      align-items: center;
      gap: 12px;
      width: 200px;
    }
  }
}
</style>

三、核心逻辑解析

3.1 多格式支持实现

通过 accept 属性限制文件选择格式,同时在代码中二次校验(避免绕过前端限制的情况):

// 格式配置(集中管理,便于维护)
acceptFormats: '.doc,.docx,.pdf,.txt,.xls,.xlsx,.csv,.pptx,.html,.json,.md',
// 选择文件时自动过滤不符合格式的文件,同时代码中无需额外处理格式校验(Element UI已处理)

3.2 批量上传与并发控制

  1. 批量选择限制:通过 fileList.length 校验单次选择和总文件数,避免超过 100 个限制;
  2. 并发控制:通过 concurrentLimit 限制同时上传的文件数(默认 5 个),避免服务器压力过大;
  3. 上传队列:未上传的文件存入 uploadQueue,上传完成后递归处理下一批,确保有序上传。

3.3 上传状态可视化

  1. 状态机设计:每个文件包含 ready(等待上传)、uploading(上传中)、finished(成功)、failed(失败)四种状态,清晰区分文件所处阶段;
  2. 进度反馈:通过 onUploadProgress 回调实时计算上传进度,配合进度条展示;
  3. 操作按钮适配:不同状态显示不同操作(上传中显示暂停、失败显示重试、等待显示开始);
  4. 筛选功能:支持按状态筛选文件,快速定位目标文件。

四、用户体验优化

  1. 拖拽上传支持拖拽文件到上传区域直接上传,配合视觉反馈(图标、提示文字)提升操作便捷性。
  2. 进度可视化上传中文件显示渐变背景进度条,直观展示上传进度:
.file-item[status="uploading"] {
  background: linear-gradient(90deg,
    rgba(64, 158, 255, 0.2) #{file.percentage}%,
    rgba(64, 158, 255, 0.1) #{file.percentage}%);
}
  1. 操作便捷性
  • 批量删除:支持全选删除和按状态筛选后删除
  • 状态快速切换:上传失败文件可直接重试,无需重新选择

打造梦幻粒子动画效果:基于 Vue 的 Canvas 实现方案

作者 胖虎265
2025年11月6日 09:56

粒子动画效果在现代网页设计中越来越受欢迎,它能为页面增添动态感和视觉吸引力。本文将分享一个基于 Vue 和 Canvas 实现的粒子动画组件,该组件具有高度可定制性,可轻松集成到各种 Web 项目中。

效果展示

image.png

实现的粒子动画具有以下特点:

  • 粒子从底部向上飘动,模拟轻盈上升的效果
  • 粒子带有呼吸式发光效果,增强视觉层次感
  • 每个粒子都有随机的大小、速度和颜色
  • 支持响应式布局,自动适应容器大小变化
  • 所有参数均可通过 props 灵活配置

实现思路

技术选择

为什么选择 Canvas 而非 DOM 元素来实现粒子效果?

  1. 性能优势:Canvas 在处理大量粒子时性能远优于 DOM 操作
  2. 绘制灵活性:Canvas 提供丰富的绘图 API,便于实现复杂的视觉效果
  3. 资源占用低:相比创建大量 DOM 节点,Canvas 渲染更高效

核心实现步骤

  1. 初始化 Canvas 并设置合适的尺寸
  2. 创建粒子类,定义粒子的属性和行为
  3. 实现粒子的绘制逻辑,包括发光效果
  4. 构建动画循环,更新粒子状态
  5. 添加响应式处理和组件生命周期管理

代码解析

组件结构

组件主要分为三个部分:模板 (template)、脚本 (script) 和样式 (style)。

<template>
  <div class="particle-container">
    <canvas ref="particleCanvas" class="particle-canvas"></canvas>
  </div>
</template>

模板部分非常简洁,只包含一个容器和 canvas 元素,canvas 将作为我们绘制粒子的画布。

可配置参数

为了使组件具有良好的可定制性,我们定义了以下 props:

运行

props: {
  // 粒子数量
  particleCount: {
    type: Number,
    default: 50,
    validator: (value) => value >= 0
  },
  // 粒子颜色数组
  particleColors: {
    type: Array,
    default: () => [
      'rgba(255, 255, 255,',    // 白色
      'rgba(153, 204, 255,',   // 淡蓝
      'rgba(255, 204, 255,',   // 淡粉
      'rgba(204, 255, 255,'    // 淡青
    ]
  },
  // 发光强度
  glowIntensity: {
    type: Number,
    default: 1.5
  },
  // 粒子大小控制参数
  minParticleSize: {
    type: Number,
    default: 0.5  // 最小粒子半径
  },
  maxParticleSize: {
    type: Number,
    default: 1.5  // 最大粒子半径
  }
}

这些参数允许开发者根据需求调整粒子效果的密度、颜色、大小和发光强度。

粒子创建与初始化

createParticle() {
  // 根据传入的范围计算粒子半径
  const radius = this.minParticleSize + Math.random() * (this.maxParticleSize - this.minParticleSize)

  return {
    x: Math.random() * this.canvasWidth,
    y: this.canvasHeight + Math.random() * 50,
    radius,  // 使用新的半径范围
    color: this.getRandomColor(),
    speedY: Math.random() * 1.5 + 0.5,  // 垂直速度
    speedX: (Math.random() - 0.5) * 0.3,  // 水平漂移
    alpha: Math.random() * 0.5 + 0.5,
    life: Math.random() * 150 + 150,  // 生命周期
    glow: Math.random() * 0.8 + 0.2,
    glowSpeed: (Math.random() - 0.5) * 0.02,
    shadowBlur: radius * 3 + 1  // 阴影模糊与粒子大小成比例
  }
}

每个粒子都有随机的初始位置(从底部进入)、大小、速度和发光属性,这确保了动画效果的自然和丰富性。

动画循环

动画的核心是animate方法,它使用requestAnimationFrame创建流畅的动画循环:

animate() {
  this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)

  this.particles.forEach((particle, index) => {
    // 更新粒子位置
    particle.y -= particle.speedY
    particle.x += particle.speedX
    particle.life--

    // 处理发光动画
    particle.glow += particle.glowSpeed
    if (particle.glow > 1.2) {
      particle.glow = 1.2
      particle.glowSpeed = -particle.glowSpeed
    } else if (particle.glow < 0.2) {
      particle.glow = 0.2
      particle.glowSpeed = -particle.glowSpeed
    }

    // 粒子生命周期结束,重新创建
    if (particle.y < -particle.radius || particle.life <= 0) {
      this.particles[index] = this.createParticle()
    }

    // 绘制粒子(包括发光效果、核心和高光)
    // ...绘制代码省略
  })

  this.animationId = requestAnimationFrame(this.animate)
}

在每次动画帧中,我们更新所有粒子的位置和状态,当粒子超出画布或生命周期结束时,会创建新的粒子替换它,从而实现循环不断的动画效果。

响应式处理

为了使粒子动画适应不同屏幕尺寸,我们添加了窗口大小变化的监听:

handleResize() {
  this.initCanvas()
  this.particles = this.particles.map(() => this.createParticle())
}

当窗口大小改变时,我们重新初始化 Canvas 尺寸并重新创建所有粒子,确保动画始终充满整个容器。

使用方法

使用该组件非常简单,只需在你的 Vue 项目中引入并注册,然后在模板中使用:

<template>
  <div class="page-container">
    <ParticleAnimation 
      :particle-count="80"
      :glow-intensity="2"
      :min-particle-size="0.8"
      :max-particle-size="2"
    />
    <!-- 其他内容 -->
  </div>
</template>

<script>
import ParticleAnimation from '@/components/ParticleAnimation.vue'

export default {
  components: {
    ParticleAnimation
  }
}
</script>

<style>
.page-container {
  width: 100vw;
  height: 100vh;
  background: #000; /* 深色背景更能突出粒子效果 */
}
</style>

组件完整代码

<template>
  <div class="particle-container">
    <canvas ref="particleCanvas" class="particle-canvas"></canvas>
  </div>
</template>

<script>
export default {
  name: 'ParticleAnimation',
  props: {
    // 粒子数量
    particleCount: {
      type: Number,
      default: 50,
      validator: (value) => value >= 0
    },
    // 粒子颜色数组
    particleColors: {
      type: Array,
      default: () => [
        'rgba(255, 255, 255,',    // 白色
        'rgba(153, 204, 255,',   // 淡蓝
        'rgba(255, 204, 255,',   // 淡粉
        'rgba(204, 255, 255,'    // 淡青
      ]
    },
    // 发光强度
    glowIntensity: {
      type: Number,
      default: 1.5
    },
    // 粒子大小控制参数
    minParticleSize: {
      type: Number,
      default: 0.5  // 最小粒子半径
    },
    maxParticleSize: {
      type: Number,
      default: 1.5  // 最大粒子半径
    }
  },
  data() {
    return {
      canvas: null,
      ctx: null,
      particles: [],
      animationId: null,
      canvasWidth: 0,
      canvasHeight: 0
    }
  },
  watch: {
    particleCount(newVal) {
      this.particles = []
      this.initParticles(newVal)
    },
    particleColors: {
      deep: true,
      handler() {
        this.particles.forEach((particle, index) => {
          this.particles[index].color = this.getRandomColor()
        })
      }
    },
    // 监听粒子大小变化
    minParticleSize() {
      this.resetParticles()
    },
    maxParticleSize() {
      this.resetParticles()
    }
  },
  methods: {
    initCanvas() {
      this.canvas = this.$refs.particleCanvas
      this.ctx = this.canvas.getContext('2d')

      const container = this.canvas.parentElement
      this.canvasWidth = container.clientWidth
      this.canvasHeight = container.clientHeight
      this.canvas.width = this.canvasWidth
      this.canvas.height = this.canvasHeight
    },

    initParticles(count) {
      for (let i = 0; i < count; i++) {
        this.particles.push(this.createParticle())
      }
    },

    createParticle() {
      // 根据传入的范围计算粒子半径
      const radius = this.minParticleSize + Math.random() * (this.maxParticleSize - this.minParticleSize)

      return {
        x: Math.random() * this.canvasWidth,
        y: this.canvasHeight + Math.random() * 50,
        radius,  // 使用新的半径范围
        color: this.getRandomColor(),
        speedY: Math.random() * 1.5 + 0.5,  // 降低速度,配合小粒子
        speedX: (Math.random() - 0.5) * 0.3,  // 减少漂移
        alpha: Math.random() * 0.5 + 0.5,
        life: Math.random() * 150 + 150,  // 延长生命周期,让小粒子存在更久
        glow: Math.random() * 0.8 + 0.2,
        glowSpeed: (Math.random() - 0.5) * 0.02,
        shadowBlur: radius * 3 + 1  // 阴影模糊与粒子大小成比例
      }
    },

    getRandomColor() {
      if (this.particleColors.length === 0) {
        return 'rgba(255, 255, 255,'
      }
      return this.particleColors[Math.floor(Math.random() * this.particleColors.length)]
    },

    animate() {
      this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)

      this.particles.forEach((particle, index) => {
        particle.y -= particle.speedY
        particle.x += particle.speedX
        particle.life--

        // 闪亮动画
        particle.glow += particle.glowSpeed
        if (particle.glow > 1.2) {
          particle.glow = 1.2
          particle.glowSpeed = -particle.glowSpeed
        } else if (particle.glow < 0.2) {
          particle.glow = 0.2
          particle.glowSpeed = -particle.glowSpeed
        }

        if (particle.y < -particle.radius || particle.life <= 0) {
          this.particles[index] = this.createParticle()
        }

        // 绘制粒子(适配小粒子的比例)
        this.ctx.save()

        // 阴影效果
        this.ctx.shadowColor = `${particle.color}${particle.glow * this.glowIntensity})`
        this.ctx.shadowBlur = particle.shadowBlur * particle.glow
        this.ctx.shadowOffsetX = 0
        this.ctx.shadowOffsetY = 0

        // 外发光圈(按粒子大小比例缩放)
        this.ctx.beginPath()
        this.ctx.arc(particle.x, particle.y, particle.radius * (1 + particle.glow * 0.8), 0, Math.PI * 2)
        this.ctx.fillStyle = `${particle.color}${0.2 * particle.glow})`
        this.ctx.fill()

        // 粒子核心
        this.ctx.beginPath()
        this.ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2)
        this.ctx.fillStyle = `${particle.color}${particle.alpha + (particle.glow * 0.3)})`
        this.ctx.fill()

        // 高光点(适配小粒子)
        if (particle.glow > 0.8) {
          this.ctx.beginPath()
          const highlightSize = particle.radius * 0.3 * particle.glow
          this.ctx.arc(
            particle.x - particle.radius * 0.2,
            particle.y - particle.radius * 0.2,
            highlightSize,
            0,
            Math.PI * 2
          )
          this.ctx.fillStyle = `rgba(255, 255, 255, ${0.6 * particle.glow})`
          this.ctx.fill()
        }

        this.ctx.restore()
      })

      this.animationId = requestAnimationFrame(this.animate)
    },

    handleResize() {
      this.initCanvas()
      this.particles = this.particles.map(() => this.createParticle())
    },

    // 重置粒子大小
    resetParticles() {
      this.particles = this.particles.map(() => this.createParticle())
    }
  },
  mounted() {
    this.initCanvas()
    this.initParticles(this.particleCount)
    this.animate()
    window.addEventListener('resize', this.handleResize)
  },
  beforeDestroy() {
    cancelAnimationFrame(this.animationId)
    window.removeEventListener('resize', this.handleResize)
  }
}
</script>

<style scoped>
.particle-container {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.particle-canvas {
  display: block;
  width: 100%;
  height: 100%;
}
</style>
昨天以前首页

实现无缝滚动无滚动条的 Element UI 表格(附完整代码)

作者 胖虎265
2025年11月4日 11:04

实现无缝滚动无滚动条的 Element UI 表格(附完整代码)

在后台管理系统或数据监控场景中,经常需要实现表格无缝滚动展示数据,同时希望隐藏滚动条保持界面整洁。本文将基于 Element UI 实现一个 无滚动条、无缝循环、hover 暂停、状态高亮 的高性能滚动表格,全程流畅无卡顿,适配多浏览器。

1.gif

最终效果

  • 🚀 无缝循环滚动,无停顿、无跳跃
  • 🚫 视觉上完全隐藏滚动条,保留滚动功能
  • 🛑 鼠标悬浮自动暂停,离开恢复滚动
  • 🌈 支持状态字段高亮(如不同状态显示不同颜色)
  • 🎨 美观的表格样式,hover 行高亮反馈
  • 🛠 高度可配置(行高、滚动速度、表格高度等)

技术栈

  • Vue 2 + Element UI(适配 Vue 2 项目,Vue 3 可快速迁移)
  • SCSS(样式模块化,便于维护)

实现思路

  1. 无缝滚动核心:通过「数据拼接」(原数据 + 原数据副本)实现视觉上的无限循环,滚动到原数据末尾时瞬间重置滚动位置,无感知切换
  2. 隐藏滚动条:多浏览器兼容 CSS 屏蔽滚动条样式,同时预留滚动条宽度避免内容裁剪
  3. 流畅滚动优化:避免 DOM 频繁重绘,用 scrollTop 控制滚动,关闭平滑滚动避免停顿
  4. 交互增强:hover 暂停滚动、行 hover 高亮、状态字段颜色区分

配置说明

参数名 类型 默认值 说明
tableData Array [] 表格数据源(必传)
columns Array [] 列配置(必传,支持 statusConfig 状态样式)
rowHeight Number 36 行高(单位:px)
scrollSpeed Number 20 滚动速度(毫秒 / 像素),值越小越快
scrollPauseOnHover Boolean true 鼠标悬浮是否暂停滚动
tableHeight Number 300 表格高度(父组件配置)

完整代码实现

1. 滚动表格组件(SeamlessScrollTable.vue)

<template>
  <div class="tableView">
    <el-table
      :data="combinedData"
      ref="scrollTable"
      style="width: 100%"
      height="100%"
      @cell-mouse-enter="handleMouseEnter"
      @cell-mouse-leave="handleMouseLeave"
      :cell-style="handleCellStyle"
      :show-header="true"
    >
      <el-table-column
        v-for="(column, index) in columns"
        v-bind="column"
        :key="index + (column.prop || index)"
        :min-width="column.minWidth || '100px'"
      >
        <template slot-scope="scope">
          <span v-if="column.statusConfig" :class="getColumnStatusClass(column, scope.row)">
            {{ scope.row[column.prop] }}
          </span>
          <span v-else>
            {{ scope.row[column.prop] }}
          </span>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script>
  export default {
    name: 'SeamlessScrollTable',
    props: {
      tableData: {
        type: Array,
        required: true,
        default: () => [],
      },
      columns: {
        type: Array,
        required: true,
        default: () => [],
      },
      rowHeight: {
        type: Number,
        default: 36,
      },
      scrollSpeed: {
        type: Number,
        default: 20, // 滚动速度(毫秒/像素),20-40ms
      },
      scrollPauseOnHover: {
        type: Boolean,
        default: true,
      },
    },
    data() {
      return {
        autoPlay: true,
        timer: null,
        offset: 0,
        combinedData: [], // 拼接后的数据,用于实现无缝滚动
      }
    },
    computed: {
      // 计算表格可滚动的总高度(仅当数据足够多时才滚动)
      scrollableHeight() {
        return this.tableData.length * this.rowHeight
      },
      // 表格容器可视高度
      viewportHeight() {
        return this.$refs.scrollTable?.$el.clientHeight || 0
      },
    },
    watch: {
      tableData: {
        handler(newVal) {
          // 数据变化时,重新拼接数据
          this.combinedData = [...newVal, ...newVal]
          this.offset = 0
          this.restartScroll()
        },
        immediate: true,
        deep: true,
      },
      autoPlay(newVal) {
        newVal ? this.startScroll() : this.pauseScroll()
      },
    },
    mounted() {
      this.$nextTick(() => {
        // 只有当数据总高度 > 可视高度时,才启动滚动
        if (this.scrollableHeight > this.viewportHeight) {
          this.startScroll()
        }
      })
    },
    beforeDestroy() {
      this.pauseScroll()
    },
    methods: {
      handleMouseEnter() {
        this.scrollPauseOnHover && (this.autoPlay = false)
      },
      handleMouseLeave() {
        this.scrollPauseOnHover && (this.autoPlay = true)
      },
      startScroll() {
        this.pauseScroll()

        const tableBody = this.$refs.scrollTable?.bodyWrapper
        if (!tableBody || this.tableData.length === 0) return

        this.timer = setInterval(() => {
          if (!this.autoPlay) return

          this.offset += 1
          tableBody.scrollTop = this.offset

          // 关键:当滚动到原数据末尾时,瞬间重置滚动位置到开头
          if (this.offset >= this.scrollableHeight) {
            this.offset = 0
            tableBody.scrollTop = 0
          }
        }, this.scrollSpeed)
      },
      pauseScroll() {
        this.timer && clearInterval(this.timer)
        this.timer = null
      },
      restartScroll() {
        this.pauseScroll()
        if (this.scrollableHeight > this.viewportHeight) {
          this.startScroll()
        }
      },
      getColumnStatusClass(column, row) {
        const statusKey = column.statusField || column.prop
        const statusValue = row[statusKey]
        return typeof column.statusConfig === 'function'
          ? column.statusConfig(statusValue, row)
          : column.statusConfig[statusValue] || ''
      },
      handleCellStyle() {
        return {
          padding: '4px 0',
          height: `${this.rowHeight}px`,
          lineHeight: `${this.rowHeight}px`,
        }
      },
    },
  }
</script>

<style scoped lang="scss">
  .tableView {
    width: 100%;
    height: 100%;
    overflow: hidden;

    ::v-deep .el-table {
      background-color: transparent;
      color: #303133;
      border-collapse: separate;
      border-spacing: 0;

      &::before {
        display: none;
      }

      th.el-table__cell.is-leaf {
        border-bottom: 1px solid rgba(0, 0, 0, 0.1);
        background: transparent !important;
        font-weight: 500;
        color: rgba(0, 0, 0, 0.6);
        padding: 8px 0;
      }

      tr.el-table__row {
        background-color: transparent;
        transition: background-color 0.2s ease;

        &:hover td {
          background-color: rgba(0, 0, 0, 0.02) !important;
        }
      }

      .el-table__cell {
        border: none;
        padding: 4px 0;

        .cell {
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
          padding: 0 8px;
        }
      }

      .el-table__body-wrapper {
        height: 100%;
        scroll-behavior: auto;
        &::-webkit-scrollbar {
          display: none !important;
          width: 0 !important;
          height: 0 !important;
        }
        scrollbar-width: none !important;
        -ms-overflow-style: none !important;
      }
    }

    ::v-deep .status-warning {
      color: #e6a23c;
      font-weight: 500;
    }

    ::v-deep .status-danger {
      color: #f56c6c;
      font-weight: 500;
    }

    ::v-deep .status-success {
      color: #67c23a;
      font-weight: 500;
    }

    ::v-deep .status-info {
      color: #409eff;
      font-weight: 500;
    }
  }
</style>

2. 父组件使用示例(TableIndex.vue)

<template>
  <div class="table-container">
    <h2 class="table-title">设备状态监控表格</h2>
    <div class="table-wrapper" :style="{ height: tableHeight + 'px' }">
      <!-- 配置滚动参数 -->
      <seamless-scroll-table
        :table-data="tableData"
        :columns="columns"
        :row-height="36"
        :scroll-speed="30"
      />
    </div>
  </div>
</template>

<script>
  import SeamlessScrollTable from './SeamlessScrollTable.vue'

  export default {
    name: 'DeviceStatusTable',
    components: { SeamlessScrollTable },
    data() {
      return {
        tableHeight: 300, // 表格高度可配置
        // 表格数据
        tableData: [
          { id: '1001', name: '设备A', type: '温度', state: '待检查' },
          { id: '1002', name: '设备B', type: '压力', state: '已超期' },
          { id: '1003', name: '设备C', type: '湿度', state: '已完成' },
          { id: '1004', name: '设备D', type: '电压', state: '超期完成' },
          { id: '1005', name: '设备E', type: '电流', state: '待检查' },
          { id: '1006', name: '设备F', type: '电阻', state: '已超期' },
          { id: '1007', name: '设备G', type: '功率', state: '已完成' },
        ],
        // 列配置
        columns: [
          { prop: 'id', label: '编号', minWidth: '140px' },
          { prop: 'name', label: '名称', width: '100px' },
          { prop: 'type', label: '设备类型', width: '120px' },
          {
            prop: 'state',
            label: '状态',
            width: '100px',
            statusField: 'state',
            // 状态样式配置(支持对象/函数)
            statusConfig: {
              待检查: 'status-warning',
              已超期: 'status-danger',
              已完成: 'status-success',
              超期完成: 'status-info',
            },
          },
        ],
      }
    },
    methods: {
      getStatusClass(state) {
        const statusMap = {
          待检查: 'status-warning',
          已超期: 'status-danger',
          已完成: 'status-success',
          超期完成: 'status-info',
        }
        return statusMap[state] || ''
      },
    },
  }
</script>

<style scoped lang="scss">
  .table-container {
    width: 100%;
    max-width: 500px;
    margin: 0 auto;
    padding: 20px;
    box-sizing: border-box;
  }

  .table-title {
    color: #303133;
    margin-bottom: 16px;
    font-size: 18px;
    font-weight: 500;
    text-align: center;
    position: relative;
  }

  .table-wrapper {
    background-color: #ffffff;
    border-radius: 8px;
    padding: 16px;
    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
    box-sizing: border-box;
  }
</style>

打造梦幻粒子动画效果:基于 Vue 的 Canvas 实现方案

作者 胖虎265
2025年10月28日 15:30

粒子动画效果在现代网页设计中越来越受欢迎,它能为页面增添动态感和视觉吸引力。本文将分享一个基于 Vue 和 Canvas 实现的粒子动画组件,该组件具有高度可定制性,可轻松集成到各种 Web 项目中。

我们实现的粒子动画具有以下特点:

  • 粒子从底部向上飘动,模拟轻盈上升的效果
  • 粒子带有呼吸式发光效果,增强视觉层次感
  • 每个粒子都有随机的大小、速度和颜色
  • 支持响应式布局,自动适应容器大小变化
  • 所有参数均可通过 props 灵活配置

技术选择

为什么选择 Canvas 而非 DOM 元素来实现粒子效果?

  1. 性能优势:Canvas 在处理大量粒子时性能远优于 DOM 操作
  2. 绘制灵活性:Canvas 提供丰富的绘图 API,便于实现复杂的视觉效果
  3. 资源占用低:相比创建大量 DOM 节点,Canvas 渲染更高效

核心实现步骤

  1. 初始化 Canvas 并设置合适的尺寸
  2. 创建粒子类,定义粒子的属性和行为
  3. 实现粒子的绘制逻辑,包括发光效果
  4. 构建动画循环,更新粒子状态
  5. 添加响应式处理和组件生命周期管理

组件结构

<template>
  <div class="particle-container">
    <canvas ref="particleCanvas" class="particle-canvas"></canvas>
  </div>
</template>

模板部分非常简洁,只包含一个容器和 canvas 元素,canvas 将作为我们绘制粒子的画布。

可配置参数

props: {
  // 粒子数量
  particleCount: {
    type: Number,
    default: 50,
    validator: (value) => value >= 0
  },
  // 粒子颜色数组
  particleColors: {
    type: Array,
    default: () => [
      'rgba(255, 255, 255,',    // 白色
      'rgba(153, 204, 255,',   // 淡蓝
      'rgba(255, 204, 255,',   // 淡粉
      'rgba(204, 255, 255,'    // 淡青
    ]
  },
  // 发光强度
  glowIntensity: {
    type: Number,
    default: 1.5
  },
  // 粒子大小控制参数
  minParticleSize: {
    type: Number,
    default: 0.5  // 最小粒子半径
  },
  maxParticleSize: {
    type: Number,
    default: 1.5  // 最大粒子半径
  }
}

这些参数允许开发者根据需求调整粒子效果的密度、颜色、大小和发光强度。

粒子创建与初始化

createParticle() {
  // 根据传入的范围计算粒子半径
  const radius = this.minParticleSize + Math.random() * (this.maxParticleSize - this.minParticleSize)

  return {
    x: Math.random() * this.canvasWidth,
    y: this.canvasHeight + Math.random() * 50,
    radius,  // 使用新的半径范围
    color: this.getRandomColor(),
    speedY: Math.random() * 1.5 + 0.5,  // 垂直速度
    speedX: (Math.random() - 0.5) * 0.3,  // 水平漂移
    alpha: Math.random() * 0.5 + 0.5,
    life: Math.random() * 150 + 150,  // 生命周期
    glow: Math.random() * 0.8 + 0.2,
    glowSpeed: (Math.random() - 0.5) * 0.02,
    shadowBlur: radius * 3 + 1  // 阴影模糊与粒子大小成比例
  }
}

每个粒子都有随机的初始位置(从底部进入)、大小、速度和发光属性,这确保了动画效果的自然和丰富性。

动画循环

动画的核心是animate方法,它使用requestAnimationFrame创建流畅的动画循环:

animate() {
  this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)

  this.particles.forEach((particle, index) => {
    // 更新粒子位置
    particle.y -= particle.speedY
    particle.x += particle.speedX
    particle.life--

    // 处理发光动画
    particle.glow += particle.glowSpeed
    if (particle.glow > 1.2) {
      particle.glow = 1.2
      particle.glowSpeed = -particle.glowSpeed
    } else if (particle.glow < 0.2) {
      particle.glow = 0.2
      particle.glowSpeed = -particle.glowSpeed
    }

    // 粒子生命周期结束,重新创建
    if (particle.y < -particle.radius || particle.life <= 0) {
      this.particles[index] = this.createParticle()
    }

    // 绘制粒子(包括发光效果、核心和高光)
    // ...绘制代码省略
  })

  this.animationId = requestAnimationFrame(this.animate)
}

在每次动画帧中,我们更新所有粒子的位置和状态,当粒子超出画布或生命周期结束时,会创建新的粒子替换它,从而实现循环不断的动画效果。

响应式处理

为了使粒子动画适应不同屏幕尺寸,我们添加了窗口大小变化的监听:

handleResize() {
  this.initCanvas()
  this.particles = this.particles.map(() => this.createParticle())
}

当窗口大小改变时,我们重新初始化 Canvas 尺寸并重新创建所有粒子,确保动画始终充满整个容器

完整代码

<template>
  <div class="particle-container">
    <canvas ref="particleCanvas" class="particle-canvas"></canvas>
  </div>
</template>

<script>
export default {
  name: 'ParticleAnimation',
  props: {
    // 粒子数量
    particleCount: {
      type: Number,
      default: 50,
      validator: (value) => value >= 0
    },
    // 粒子颜色数组
    particleColors: {
      type: Array,
      default: () => [
        'rgba(255, 255, 255,',    // 白色
        'rgba(153, 204, 255,',   // 淡蓝
        'rgba(255, 204, 255,',   // 淡粉
        'rgba(204, 255, 255,'    // 淡青
      ]
    },
    // 发光强度
    glowIntensity: {
      type: Number,
      default: 1.5
    },
    // 粒子大小控制参数
    minParticleSize: {
      type: Number,
      default: 0.5  // 最小粒子半径
    },
    maxParticleSize: {
      type: Number,
      default: 1.5  // 最大粒子半径
    }
  },
  data() {
    return {
      canvas: null,
      ctx: null,
      particles: [],
      animationId: null,
      canvasWidth: 0,
      canvasHeight: 0
    }
  },
  watch: {
    particleCount(newVal) {
      this.particles = []
      this.initParticles(newVal)
    },
    particleColors: {
      deep: true,
      handler() {
        this.particles.forEach((particle, index) => {
          this.particles[index].color = this.getRandomColor()
        })
      }
    },
    // 监听粒子大小变化
    minParticleSize() {
      this.resetParticles()
    },
    maxParticleSize() {
      this.resetParticles()
    }
  },
  methods: {
    initCanvas() {
      this.canvas = this.$refs.particleCanvas
      this.ctx = this.canvas.getContext('2d')

      const container = this.canvas.parentElement
      this.canvasWidth = container.clientWidth
      this.canvasHeight = container.clientHeight
      this.canvas.width = this.canvasWidth
      this.canvas.height = this.canvasHeight
    },

    initParticles(count) {
      for (let i = 0; i < count; i++) {
        this.particles.push(this.createParticle())
      }
    },

    createParticle() {
      // 根据传入的范围计算粒子半径
      const radius = this.minParticleSize + Math.random() * (this.maxParticleSize - this.minParticleSize)

      return {
        x: Math.random() * this.canvasWidth,
        y: this.canvasHeight + Math.random() * 50,
        radius,  // 使用新的半径范围
        color: this.getRandomColor(),
        speedY: Math.random() * 1.5 + 0.5,  // 降低速度,配合小粒子
        speedX: (Math.random() - 0.5) * 0.3,  // 减少漂移
        alpha: Math.random() * 0.5 + 0.5,
        life: Math.random() * 150 + 150,  // 延长生命周期,让小粒子存在更久
        glow: Math.random() * 0.8 + 0.2,
        glowSpeed: (Math.random() - 0.5) * 0.02,
        shadowBlur: radius * 3 + 1  // 阴影模糊与粒子大小成比例
      }
    },

    getRandomColor() {
      if (this.particleColors.length === 0) {
        return 'rgba(255, 255, 255,'
      }
      return this.particleColors[Math.floor(Math.random() * this.particleColors.length)]
    },

    animate() {
      this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)

      this.particles.forEach((particle, index) => {
        particle.y -= particle.speedY
        particle.x += particle.speedX
        particle.life--

        // 闪亮动画
        particle.glow += particle.glowSpeed
        if (particle.glow > 1.2) {
          particle.glow = 1.2
          particle.glowSpeed = -particle.glowSpeed
        } else if (particle.glow < 0.2) {
          particle.glow = 0.2
          particle.glowSpeed = -particle.glowSpeed
        }

        if (particle.y < -particle.radius || particle.life <= 0) {
          this.particles[index] = this.createParticle()
        }

        // 绘制粒子(适配小粒子的比例)
        this.ctx.save()

        // 阴影效果
        this.ctx.shadowColor = `${particle.color}${particle.glow * this.glowIntensity})`
        this.ctx.shadowBlur = particle.shadowBlur * particle.glow
        this.ctx.shadowOffsetX = 0
        this.ctx.shadowOffsetY = 0

        // 外发光圈(按粒子大小比例缩放)
        this.ctx.beginPath()
        this.ctx.arc(particle.x, particle.y, particle.radius * (1 + particle.glow * 0.8), 0, Math.PI * 2)
        this.ctx.fillStyle = `${particle.color}${0.2 * particle.glow})`
        this.ctx.fill()

        // 粒子核心
        this.ctx.beginPath()
        this.ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2)
        this.ctx.fillStyle = `${particle.color}${particle.alpha + (particle.glow * 0.3)})`
        this.ctx.fill()

        // 高光点(适配小粒子)
        if (particle.glow > 0.8) {
          this.ctx.beginPath()
          const highlightSize = particle.radius * 0.3 * particle.glow
          this.ctx.arc(
            particle.x - particle.radius * 0.2,
            particle.y - particle.radius * 0.2,
            highlightSize,
            0,
            Math.PI * 2
          )
          this.ctx.fillStyle = `rgba(255, 255, 255, ${0.6 * particle.glow})`
          this.ctx.fill()
        }

        this.ctx.restore()
      })

      this.animationId = requestAnimationFrame(this.animate)
    },

    handleResize() {
      this.initCanvas()
      this.particles = this.particles.map(() => this.createParticle())
    },

    // 重置粒子大小
    resetParticles() {
      this.particles = this.particles.map(() => this.createParticle())
    }
  },
  mounted() {
    this.initCanvas()
    this.initParticles(this.particleCount)
    this.animate()
    window.addEventListener('resize', this.handleResize)
  },
  beforeDestroy() {
    cancelAnimationFrame(this.animationId)
    window.removeEventListener('resize', this.handleResize)
  }
}
</script>

<style scoped>
.particle-container {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.particle-canvas {
  display: block;
  width: 100%;
  height: 100%;
}
</style>

使用方法

<template>
  <div class="page-container">
    <ParticleAnimation 
      :particle-count="80"
      :glow-intensity="2"
      :min-particle-size="0.8"
      :max-particle-size="2"
    />
    <!-- 其他内容 -->
  </div>
</template>

<script>
import ParticleAnimation from '@/components/ParticleAnimation.vue'

export default {
  components: {
    ParticleAnimation
  }
}
</script>

<style>
.page-container {
  width: 100vw;
  height: 100vh;
}
</style>
❌
❌