阅读视图

发现新文章,点击刷新页面。

基于 Vue3+TypeScript+Vant 的评论组件开发实践

在现代 Web 应用中,评论功能是提升用户互动性的核心模块之一。它不仅需要满足用户发表评论、回复互动的基础需求,还需要兼顾易用性、视觉体验和功能完整性。本文将结合完整代码,详细分享基于 Vue3+TypeScript+Vant 组件库开发的评论系统实现方案,从组件设计、代码实现到状态管理,层层拆解核心细节。

联想截图_20251107164213.jpg

一、整体架构设计

整个评论系统采用「组件化 + 状态管理」的架构模式,拆分为三个核心模块,各司其职且协同工作:

模块文件 核心职责 技术核心
CommentInput.vue 评论 / 回复输入弹窗,支持文本 + 表情输入 Vue3 组合式 API、Vant Popup/Field
CommentList.vue 评论列表展示,包含点赞、回复、删除等交互 条件渲染、事件监听、组件通信
comments.ts(Pinia) 全局评论状态管理,处理数据增删改查 Pinia 状态管理、TypeScript 接口定义

这种拆分遵循「单一职责原则」,让每个模块专注于自身功能,既提升了代码可维护性,也便于后续扩展。

二、核心模块代码详解

(一)评论输入组件:CommentInput.vue

负责接收用户输入(文本 + 表情),是交互入口。核心需求:支持多行输入、表情选择、内容同步、发送逻辑。

1. 模板结构核心代码

<van-popup v-model:show="show" position="bottom">
  <div class="comment-input">
    <!-- 文本输入框 -->
    <van-field
      type="textarea"
      rows="2"
      autosize
      v-model="inputValue"
      :placeholder="placeholder"
    />
    <!-- 操作栏:表情按钮+发送按钮 -->
    <div class="comment-actions">
      <van-icon name="smile-o" @click="onEmoji" />
      <van-button
        class="send-btn"
        round
        type="primary"
        :disabled="!inputValue"
        @click="handleSend"
      >发送</van-button>
    </div>
    <!-- 表情面板:折叠/展开切换 -->
    <div class="emoji-mart-wrapper" :class="{ expanded: showAllEmojis }">
      <div class="simple-emoji-list">
        <span
          v-for="(emoji, idx) in emojis"
          :key="idx"
          class="simple-emoji"
          @click="addEmojiFromPicker(emoji)"
        >{{ emoji }}</span>
      </div>
    </div>
  </div>
</van-popup>
  • 关键设计

    • van-popup实现底部弹窗,position="bottom"确保滑入效果;
    • 文本框用autosize自动适配高度,避免输入多行时滚动混乱;
    • 表情面板通过expanded类控制高度过渡(48px→240px),配合overflow-y:auto支持滚动。

2. 逻辑核心代码

import { ref, watch, defineProps, defineEmits } from 'vue'

// 定义props和emit,实现父子组件通信
const props = defineProps({
  show: Boolean,
  modelValue: String,
  placeholder: { type: String, default: '友善发言,理性交流' }
})
const emit = defineEmits(['update:show', 'update:modelValue', 'send'])

// 响应式变量
const show = ref(props.show) // 弹窗显示状态
const inputValue = ref(props.modelValue || '') // 输入内容
const showAllEmojis = ref(false) // 表情面板展开状态

// 表情库(包含表情、动物、食物等多分类)
const emojis = ['😀', '😁', '😂', ...] // 完整列表见原代码

// 监听props变化,同步到组件内部状态
watch(() => props.show, v => show.value = v)
watch(show, v => emit('update:show', v)) // 双向绑定弹窗状态
watch(() => props.modelValue, val => inputValue.value = val)
watch(inputValue, val => emit('update:modelValue', val)) // 同步输入内容

// 表情面板展开/收起切换
function onEmoji() {
  showAllEmojis.value = !showAllEmojis.value
}

// 选择表情:拼接至输入框
function addEmojiFromPicker(emoji: string) {
  inputValue.value += emoji
  emit('update:modelValue', inputValue.value)
}

// 发送评论
function handleSend() {
  if (!inputValue.value) return
  emit('send', inputValue.value) // 向父组件传递输入内容
  inputValue.value = '' // 清空输入框
  emit('update:modelValue', '')
  showAllEmojis.value = false // 收起表情面板
  show.value = false // 关闭弹窗
}
  • 关键逻辑

    • watch实现 props 与组件内部状态的双向同步,确保父子组件数据一致;
    • 表情选择直接拼接字符串,无需处理光标位置,简化逻辑;
    • 发送按钮通过!inputValue控制禁用状态,避免空内容提交。

3. 样式优化(SCSS)

.emoji-mart-wrapper {
  background: #fff;
  height: 48px;
  max-height: 48px;
  overflow-y: hidden;
  transition: max-height 0.3s, height 0.3s; // 平滑过渡
  &.expanded {
    height: 240px;
    max-height: 240px;
    overflow-y: auto;
  }
}
.simple-emoji {
  font-size: 24px;
  cursor: pointer;
  transition: transform 0.1s;
  &:hover {
    transform: scale(1.2); //  hover放大,提升交互反馈
  }
}

(二)评论列表组件:CommentList.vue

核心展示与交互模块,负责评论列表渲染、回复、点赞、删除、长按操作等。

1. 模板结构核心代码

<div class="comment-list">
  <!-- 评论列表 -->
  <div v-for="(comment, idx) in showComments" :key="comment.id" class="comment-item">
    <!-- 评论者头像 -->
    <img class="avatar" :src="comment.avatar" />
    <div class="comment-main">
      <div class="nickname">{{ comment.nickname }}</div>
      <!-- 评论内容:支持@高亮,绑定点击/长按事件 -->
      <div
        class="content"
        @click="openReply(idx, undefined, comment.userId)"
        @touchstart="onTouchStart(idx, undefined, comment.content)"
        @contextmenu.prevent="onContextMenu(idx, undefined, comment.content, $event)"
        v-html="comment.content"
      ></div>
      <!-- 操作栏:时间、回复、点赞 -->
      <div class="meta">
        <span class="time">{{ comment.time }}</span>
        <span class="reply" @click="openReply(idx, undefined, comment.userId)">回复</span>
        <span class="like" @click="likeComment(idx)" :class="{ 'liked-active': comment.liked }">
          <van-icon name="good-job-o" />
          {{ comment.likes }}
        </span>
      </div>
      <!-- 回复列表:支持折叠/展开 -->
      <div v-if="comment.replies && comment.replies.length" class="reply-list">
        <div
          v-for="(reply, ridx) in showAllReplies[idx] ? comment.replies : comment.replies.slice(0, 1)"
          :key="reply.id"
          class="comment-item reply-item"
        >
          <!-- 回复内容结构与评论一致,略 -->
        </div>
        <!-- 折叠/展开按钮 -->
        <div v-if="comment.replies.length > 1" class="expand-reply" @click="toggleReplies(idx)">
          {{ showAllReplies[idx] ? '收起' : `展开${comment.replies.length}条回复` }}
        </div>
      </div>
    </div>
  </div>

  <!-- 输入回复弹窗(复用CommentInput组件) -->
  <CommentInput
    v-model="replyContent"
    v-model:show="showReplyInput"
    :placeholder="replyTarget ? `回复 @${getNicknameByUserId(replyTarget.userId)}:` : '请输入回复内容~'"
    @send="sendReply"
  />

  <!-- 长按/右键操作菜单 -->
  <van-action-sheet
    v-model:show="showActionSheet"
    :actions="actionOptions"
    @select="onActionSelect"
    cancel-text="取消"
  />
</div>
  • 关键设计

    • 评论与回复共用一套结构,通过reply-item类区分样式,减少冗余;
    • 回复列表默认显示 1 条,超过 1 条显示「展开」按钮,优化视觉体验;
    • 复用CommentInput组件实现回复输入,提升代码复用率;
    • v-html渲染内容,支持回复中的 @用户高亮(蓝色文本)。

2. 核心逻辑代码

import { ref, watch, computed, PropType } from 'vue'
import CommentInput from '@/components/CommentInput.vue'
import { useCommentsStore, Comment, Reply } from '@/store/comments'
import { useUserStore } from '@/store/user'
import { showToast } from 'vant'

// Props定义:接收评论列表和是否显示全部
const props = defineProps({
  comments: { type: Array as PropType<Comment[]>, required: true },
  showAll: { type: Boolean, default: false }
})
const emit = defineEmits(['more'])

const commentsStore = useCommentsStore() // 评论状态管理
const userStore = useUserStore() // 用户状态(获取当前登录用户)

// 回复相关状态
const showReplyInput = ref(false) // 回复弹窗显示状态
const replyContent = ref('') // 回复内容
const replyTarget = ref<{ commentIdx: number; replyIdx?: number; userId: string } | null>(null) // 回复目标

// 控制回复列表折叠/展开
const showAllReplies = ref(props.comments.map(() => false))
watch(() => props.comments, val => {
  showAllReplies.value = val.map(() => false) // 评论列表变化时重置折叠状态
}, { immediate: true })

// 评论列表分页:默认显示2条,showAll为true时显示全部
const showComments = computed(() => {
  return props.showAll ? props.comments : props.comments.slice(0, 2)
})

// 当前登录用户ID(用于权限控制)
const currentUserId = computed(() => userStore.userInfo?.id?.toString() || 'anonymous')

// 1. 点赞评论
function likeComment(idx: number) {
  const comment = showComments.value[idx]
  commentsStore.likeComment(comment.id) // 调用Pinia Action修改状态
}

// 2. 回复评论/回复
function openReply(commentIdx: number, replyIdx?: number, userId?: string) {
  replyTarget.value = { commentIdx, replyIdx, userId: userId || '' }
  showReplyInput.value = true
  replyContent.value = '' // 清空输入框
}

// 3. 发送回复
function sendReply(val: string) {
  if (!val || !replyTarget.value) return
  const { commentIdx, replyIdx } = replyTarget.value
  const comment = showComments.value[commentIdx]
  let content = val
  // 回复某条回复时,添加@提及
  if (replyIdx !== undefined && comment.replies[replyIdx]) {
    content = `<span style='color:#409EFF'>@${comment.replies[replyIdx].nickname}</span> ${val}`
  }
  // 调用Pinia Action添加回复
  const userInfo = userStore.userInfo
  const reply: Reply = {
    id: Date.now(), // 用时间戳作为唯一ID
    avatar: userInfo?.avatar || getAssetUrl(userInfo?.gender === 'female' ? 'avatar_woman.svg' : 'avatar_man.svg'),
    nickname: userInfo?.nickname || '匿名用户',
    userId: userInfo?.id?.toString() || 'anonymous',
    content,
    time: new Date().toLocaleString(),
    likes: 0
  }
  commentsStore.addReply(comment.id, reply)
  showReplyInput.value = false
}

// 4. 长按/右键操作(复制/删除)
const showActionSheet = ref(false)
const actionOptions = ref([{ name: '复制' }, { name: '删除' }])
const actionTarget = ref<{ commentIdx: number; replyIdx?: number; content: string } | null>(null)
let touchTimer: any = null

// 设置操作菜单(只有自己的内容才显示删除)
function setActionOptions(commentIdx: number, replyIdx?: number) {
  let canDelete = false
  if (replyIdx !== undefined) {
    const comment = showComments.value[commentIdx]
    canDelete = comment.replies[replyIdx].userId === currentUserId.value
  } else {
    const comment = showComments.value[commentIdx]
    canDelete = comment.userId === currentUserId.value
  }
  actionOptions.value = canDelete ? [{ name: '复制' }, { name: '删除' }] : [{ name: '复制' }]
}

// 移动端长按触发
function onTouchStart(commentIdx: number, replyIdx: number | undefined, content: string) {
  setActionOptions(commentIdx, replyIdx)
  touchTimer = setTimeout(() => {
    actionTarget.value = { commentIdx, replyIdx, content }
    showActionSheet.value = true
  }, 500)
}

// 长按取消
function onTouchEnd() {
  if (touchTimer) clearTimeout(touchTimer)
}

// PC端右键菜单
function onContextMenu(commentIdx: number, replyIdx: number | undefined, content: string, e: Event) {
  e.preventDefault()
  setActionOptions(commentIdx, replyIdx)
  actionTarget.value = { commentIdx, replyIdx, content }
  showActionSheet.value = true
}

// 操作菜单选择(复制/删除)
async function onActionSelect(action: { name: string }) {
  if (!actionTarget.value) return
  const { commentIdx, replyIdx, content } = actionTarget.value
  if (action.name === '复制') {
    // 提取纯文本(过滤HTML标签)
    const tempDiv = document.createElement('div')
    tempDiv.innerHTML = content
    await navigator.clipboard.writeText(tempDiv.innerText)
    showToast('已复制')
  } else if (action.name === '删除') {
    if (replyIdx !== undefined) {
      commentsStore.deleteReply(showComments.value[commentIdx].id, showComments.value[commentIdx].replies[replyIdx].id)
    } else {
      commentsStore.deleteComment(showComments.value[commentIdx].id)
    }
    showToast('已删除')
  }
  showActionSheet.value = false
}
  • 关键逻辑

    • 权限控制:通过currentUserId与评论 / 回复的userId比对,仅显示自己内容的删除按钮;
    • 回复 @提及:回复特定用户时,自动拼接<span>标签实现蓝色高亮;
    • 兼容移动端 / PC 端:通过touchstart/touchend处理长按,contextmenu处理右键菜单;
    • 分页与折叠:评论列表默认显示 2 条,回复列表默认显示 1 条,优化长列表渲染性能。

(三)状态管理:comments.ts(Pinia)

负责管理评论全局状态,提供统一的数据操作 API,避免组件间数据传递混乱。

1. 数据模型定义(TypeScript 接口)

// 回复数据模型
export interface Reply {
  id: number
  avatar: string
  nickname: string
  userId: string
  content: string
  time: string
  likes: number
  liked?: boolean // 是否点赞
}

// 评论数据模型
export interface Comment {
  id: number
  avatar: string
  nickname: string
  userId: string
  content: string
  time: string
  likes: number
  liked?: boolean
  replies: Reply[] // 关联的回复列表
}
  • 用 TypeScript 接口定义数据结构,确保类型安全,减少开发时的类型错误。

2. Pinia Store 核心代码

import { defineStore } from 'pinia'
import { getAssetUrl } from '@/utils/index'
import { Comment, Reply } from './types'

export const useCommentsStore = defineStore('comments', {
  state: () => ({
    // 初始测试数据
    comments: [
      {
        id: 1,
        avatar: getAssetUrl('avatar_woman.svg'),
        nickname: '徐济锐',
        userId: 'xujirui',
        content: '内容详细丰富,详细的介绍了电信业务稽核系统技术规范,条理清晰。',
        time: '2025-06-09 17:08:17',
        likes: 4,
        replies: [
          {
            id: 11,
            avatar: getAssetUrl('avatar_man.svg'),
            nickname: '张亮',
            userId: 'zhangliang',
            content: '文本编辑调理清晰,很不错!',
            time: '2025-06-09 17:08:17',
            likes: 4
          }
        ]
      },
      // 更多测试数据...
    ] as Comment[]
  }),
  actions: {
    // 添加评论(插入到列表头部)
    addComment(comment: Comment) {
      this.comments.unshift(comment)
    },
    // 给指定评论添加回复
    addReply(commentId: number, reply: Reply) {
      const comment = this.comments.find(c => c.id === commentId)
      if (comment) comment.replies.push(reply)
    },
    // 点赞/取消点赞评论
    likeComment(id: number) {
      const comment = this.comments.find(c => c.id === id)
      if (comment) {
        comment.liked = !comment.liked
        comment.likes += comment.liked ? 1 : -1
      }
    },
    // 点赞/取消点赞回复
    likeReply(commentId: number, replyId: number) {
      const comment = this.comments.find(c => c.id === commentId)
      if (comment) {
        const reply = comment.replies.find(r => r.id === replyId)
        if (reply) {
          reply.liked = !reply.liked
          reply.likes += reply.liked ? 1 : -1
        }
      }
    },
    // 删除评论
    deleteComment(id: number) {
      this.comments = this.comments.filter(c => c.id !== id)
    },
    // 删除回复
    deleteReply(commentId: number, replyId: number) {
      const comment = this.comments.find(c => c.id === commentId)
      if (comment) {
        comment.replies = comment.replies.filter(r => r.id !== replyId)
      }
    }
  }
})
  • 关键设计

    • 所有数据操作都通过 Action 方法实现,组件无需直接修改 State,确保数据流向清晰;
    • 点赞逻辑通过liked状态切换,同步更新likes计数,避免重复点赞;
    • 初始测试数据模拟真实场景,便于开发调试。

三、核心技术亮点

  1. TypeScript 类型安全:从组件 Props 到 Pinia 状态,全程使用 TypeScript 接口约束,减少类型错误,提升开发体验;
  2. 组件复用CommentInput组件同时支持评论和回复输入,避免重复开发;
  3. 交互体验优化:表情面板平滑过渡、点赞状态切换反馈、长按防误触(500ms 延迟)、空状态提示;
  4. 性能优化:评论 / 回复列表分页渲染、折叠显示,减少 DOM 节点数量;;
  5. 权限控制:仅当前登录用户可删除自己的评论 / 回复,提升数据安全性。

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

在工业数字化转型过程中,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 图标、代码格式化、权限指令 + 主题切换

在 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 中补充专属样式即可。

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

在文件上传类应用中,多格式兼容批量处理能力清晰的状态反馈是提升用户体验的关键。本文将聚焦这三大核心需求,基于 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 实现方案

粒子动画效果在现代网页设计中越来越受欢迎,它能为页面增添动态感和视觉吸引力。本文将分享一个基于 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>
❌