瀏覽代碼

手持端图片路径问题解决

zengjun 1 月之前
父節點
當前提交
34fa507a49
共有 5 個文件被更改,包括 1748 次插入0 次删除
  1. 1345 0
      src/components/EditImage.vue
  2. 156 0
      src/composables/useImageUpload.ts
  3. 57 0
      src/types/editImage.ts
  4. 28 0
      src/types/upload.ts
  5. 162 0
      src/utils/timerManager.ts

+ 1345 - 0
src/components/EditImage.vue

@@ -0,0 +1,1345 @@
+<template>
+  <van-popup
+    v-model:show="visible"
+    position="bottom"
+    closeable
+    close-icon="close"
+    :style="{ height: '100%' }"
+    @close="handleCancel"
+  >
+    <div class="edit-image-container">
+      <!-- 顶部标题栏 -->
+      <div class="header">
+        <div class="title">图片编辑</div>
+        <div class="actions">
+          <van-button size="small" @click="handleCancel">取消</van-button>
+          <van-button type="primary" size="small" @click="handleSave">保存</van-button>
+        </div>
+      </div>
+
+      <!-- Canvas区域 -->
+      <div ref="canvasContainerEl" class="canvas-container">
+        <canvas ref="canvasEl" class="image-canvas"></canvas>
+      </div>
+
+      <!-- 底部工具栏 -->
+      <div class="toolbar">
+        <div class="tool-group">
+          <button
+            v-for="tool in tools"
+            :key="tool.id"
+            :class="['tool-btn', { active: currentTool === tool.id }]"
+            @click="selectTool(tool.id)"
+          >
+            <van-icon v-if="tool.vanIcon" :name="tool.vanIcon" class="tool-icon" />
+            <span v-else class="tool-icon tool-text-icon">{{ tool.icon }}</span>
+            <span class="tool-label">{{ tool.label }}</span>
+          </button>
+        </div>
+
+        <!-- 操作按钮组 -->
+        <div class="action-group">
+          <van-button
+            size="small"
+            @click="handleUndo"
+            :disabled="!canUndo"
+            plain
+            class="action-btn"
+          >
+            撤销
+          </van-button>
+          <van-button
+            size="small"
+            @click="handleRedo"
+            :disabled="!canRedo"
+            plain
+            class="action-btn"
+          >
+            重做
+          </van-button>
+          <van-button
+            size="small"
+            @click="handleReset"
+            plain
+            class="action-btn"
+          >
+            重置
+          </van-button>
+          <van-button
+            size="small"
+            round
+            class="settings-btn"
+            @click="showPropertyPanel = !showPropertyPanel"
+          >
+            <van-icon name="setting-o" />
+          </van-button>
+        </div>
+      </div>
+
+      <!-- 工具属性面板 -->
+      <div v-if="showPropertyPanel" class="property-panel">
+        <div class="property-group">
+          <div class="property-item">
+            <span class="property-label">颜色:</span>
+            <div class="color-picker">
+              <div
+                v-for="color in colors"
+                :key="color"
+                :class="['color-option', { active: currentColor === color }]"
+                :style="{ backgroundColor: color }"
+                @click="currentColor = color"
+              ></div>
+            </div>
+          </div>
+          <div class="property-item">
+            <span class="property-label">粗细:</span>
+            <van-slider
+              v-model="strokeWidth"
+              :min="1"
+              :max="10"
+              :step="1"
+              bar-height="4px"
+              active-color="#1989fa"
+            />
+            <span class="slider-value">{{ strokeWidth }}px</span>
+          </div>
+          <div class="property-item" v-if="currentTool === 'text'">
+            <span class="property-label">字体大小:</span>
+            <van-slider
+              v-model="fontSize"
+              :min="8"
+              :max="72"
+              :step="2"
+              bar-height="4px"
+              active-color="#1989fa"
+            />
+            <span class="slider-value">{{ fontSize }}px</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </van-popup>
+</template>
+
+<script setup lang="ts">
+import { ref, shallowRef, onMounted, onUnmounted, nextTick, watch } from 'vue'
+import { showNotify } from 'vant'
+import * as fabric from 'fabric'
+import {
+  ZOOM_CONFIG,
+  HISTORY_CONFIG,
+  SHAPE_CONFIG,
+  TEXT_CONFIG,
+  COLOR_LIST,
+  MOBILE_LONGPRESS_DELAY,
+  CANVAS_CONFIG,
+  SLIDER_RANGES,
+} from '@/constants/editImageConfig'
+import type {
+  ImageItem,
+  EditImageExpose,
+  ToolMode,
+  ToolDefinition,
+} from '@/types/editImage'
+import { TimerManager } from '@/utils/timerManager'
+
+// 响应式数据
+const visible = ref(false)
+const canvasEl = ref<HTMLCanvasElement | null>(null)
+const canvasContainerEl = ref<HTMLElement | null>(null)
+const canvas = shallowRef<fabric.Canvas | null>(null)
+const currentImage = ref<ImageItem | null>(null)
+const currentType = ref<'outer' | 'inner'>('outer')
+
+// 工具状态
+const currentTool = ref<ToolMode>('select')
+const currentColor = ref('#ff3b30')
+const strokeWidth = ref(3)
+const fontSize = ref(16)
+const showPropertyPanel = ref(false)
+
+// 缩放和平移状态
+const zoomLevel = ref(1)
+const minZoom = ZOOM_CONFIG.MIN_ZOOM
+const maxZoom = ZOOM_CONFIG.MAX_ZOOM
+const isPanning = ref(false)
+let panStartX = 0
+let panStartY = 0
+// 历史记录
+const historyStack = ref<string[]>([])
+const historyIndex = ref(-1)
+const canUndo = ref(false)
+const canRedo = ref(false)
+// 正在从历史记录恢复时设为 true,阻止 saveHistory 被 object:added/removed 事件重复触发
+let isRestoring = false
+
+// 历史记录防抖定时器:避免 slider 拖动产生大量历史条目
+let watchSaveHistoryTimer: ReturnType<typeof setTimeout> | null = null
+
+// 键盘快捷键处理函数(模块级别定义,确保 onUnmounted 能正确移除)
+const handleKeyDown = (e: KeyboardEvent) => {
+  if (!visible.value) return
+
+  // Ctrl+Z / Cmd+Z - 撤销
+  if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
+    e.preventDefault()
+    if (canUndo.value) handleUndo()
+    return
+  }
+
+  // Ctrl+Y / Ctrl+Shift+Z - 重做
+  if ((e.ctrlKey && e.key === 'y') || ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey)) {
+    e.preventDefault()
+    if (canRedo.value) handleRedo()
+    return
+  }
+}
+
+// 监听属性变化,更新选中的对象(历史记录保存使用防抖,避免 slider 拖动产生大量历史条目)
+watch([currentColor, strokeWidth, fontSize], () => {
+  if (!canvas.value) return
+
+  const activeObject = canvas.value.getActiveObject()
+  if (activeObject) {
+    // 立即更新对象属性
+    if (activeObject.stroke !== undefined) {
+      activeObject.set('stroke', currentColor.value)
+    }
+    if (activeObject.fill !== undefined) {
+      activeObject.set('fill', currentColor.value)
+    }
+    if (activeObject.strokeWidth !== undefined) {
+      activeObject.set('strokeWidth', strokeWidth.value)
+    }
+    if (activeObject.type === 'i-text' || activeObject.type === 'textbox') {
+      const textObj = activeObject as fabric.IText
+      textObj.set('fontSize', fontSize.value)
+    }
+    canvas.value.renderAll()
+
+    // 延迟 500ms 保存历史,合并连续拖动操作
+    if (watchSaveHistoryTimer) clearTimeout(watchSaveHistoryTimer)
+    watchSaveHistoryTimer = setTimeout(() => {
+      saveHistory()
+      watchSaveHistoryTimer = null
+    }, 500)
+  }
+})
+
+// 工具定义
+const tools = [
+  { id: 'select' as ToolMode, icon: '', vanIcon: 'like-o', label: '选择' },
+  { id: 'pan' as ToolMode, icon: '', vanIcon: 'replay', label: '移动' },
+  { id: 'rect' as ToolMode, icon: '', vanIcon: 'stop-o', label: '矩形' },
+  { id: 'circle' as ToolMode, icon: '', vanIcon: 'circle-o', label: '圆形' },
+  { id: 'arrow' as ToolMode, icon: '', vanIcon: 'share-o', label: '箭头' },
+  { id: 'text' as ToolMode, icon: 'T', vanIcon: '', label: '文字' },
+]
+
+// 颜色选项
+const colors = [
+  '#ff3b30', // 红色 - 用于标记问题
+  '#007aff', // 蓝色 - 用于标记注意
+  '#34c759', // 绿色 - 用于标记正常
+  '#ff9500', // 橙色 - 用于标记警告
+  '#5856d6', // 紫色 - 用于标记其他
+  '#000000', // 黑色
+]
+
+// 事件定义
+const emit = defineEmits<{
+  'save-image': [dataURL: string, type: 'outer' | 'inner']
+  cancel: []
+}>()
+
+// 暴露给父组件的方法
+defineExpose<EditImageExpose>({
+  editImage: (imageObj: ImageItem, type: 'outer' | 'inner') => {
+    currentImage.value = imageObj
+    currentType.value = type
+    visible.value = true
+
+    // 取图片来源:优先已上传 url,其次 van-uploader 生成的 base64 content,兜底从 file 生成 ObjectURL
+    const imageSource =
+      imageObj.url ||
+      imageObj.content ||
+      (imageObj.file ? URL.createObjectURL(imageObj.file) : null)
+
+    if (!imageSource) {
+      console.warn('[EditImage] 无可用图片来源')
+      return
+    }
+
+    // 等待 popup 动画完成(约 300ms)后再加载,防止 canvas 尺寸为 0
+    setTimeout(() => {
+      if (!canvas.value) {
+        // canvas 未初始化时重新初始化
+        nextTick(() => {
+          initCanvas()
+          loadImageToCanvas(imageSource)
+        })
+      } else {
+        loadImageToCanvas(imageSource)
+      }
+    }, 350)
+  }
+})
+
+// 初始化Canvas
+
+// 清理资源
+onUnmounted(() => {
+  if (canvas.value) {
+    canvas.value.dispose()
+    canvas.value = null
+  }
+  // 移除全局键盘事件监听,防止内存泄漏
+  window.removeEventListener('keydown', handleKeyDown)
+  // 清理防抖定时器
+  if (watchSaveHistoryTimer) clearTimeout(watchSaveHistoryTimer)
+})
+
+// 初始化Canvas
+function initCanvas() {
+  if (!canvasEl.value) return
+
+  // 优先用容器实际尺寸,如果读不到则先用屏幕尺寸兑底
+  const containerW = canvasContainerEl.value?.clientWidth || window.innerWidth
+  const containerH = canvasContainerEl.value?.clientHeight || (window.innerHeight - 200)
+
+  canvas.value = new fabric.Canvas(canvasEl.value, {
+    width: containerW,
+    height: containerH,
+    backgroundColor: '#f5f5f5',
+    preserveObjectStacking: true,
+    // 移动端优化配置
+    allowTouchScrolling: false,
+    enablePointerEvents: true,
+    fireRightClick: true,
+    stopContextMenu: true,
+    touchCornerSize: 24,
+  })
+
+  // 设置画布事件监听
+  setupCanvasEvents()
+  setupZoom()
+}
+
+// 设置画布事件
+function setupCanvasEvents() {
+  if (!canvas.value) return
+
+  // 对象选择变化
+  canvas.value.on('selection:created', () => {
+    updatePropertyPanel()
+  })
+
+  canvas.value.on('selection:updated', () => {
+    updatePropertyPanel()
+  })
+
+  canvas.value.on('selection:cleared', () => {
+    updatePropertyPanel()
+  })
+
+  // 对象修改后保存历史
+  canvas.value.on('object:modified', () => {
+    saveHistory()
+  })
+
+  canvas.value.on('object:added', () => {
+    saveHistory()
+  })
+
+  canvas.value.on('object:removed', () => {
+    saveHistory()
+  })
+}
+
+// 加载图片到Canvas
+async function loadImageToCanvas(imageUrl: string) {
+  if (!canvas.value) return
+
+  try {
+    // 清除现有内容(Fabric v6+ 已移除 setBackgroundColor / setBackgroundImage 回调形式)
+    canvas.value.clear()
+    canvas.value.backgroundColor = '#f5f5f5'
+
+    // 重置视口变换(缩放/平移),确保重新进入时画板居中
+    canvas.value.setViewportTransform([1, 0, 0, 1, 0, 0])
+    zoomLevel.value = 1
+
+    canvas.value.requestRenderAll()
+
+    // 创建图片元素,crossOrigin 必须在 src 赋值前设置,避免远程 URL CORS 问题
+    const imgElement = document.createElement('img')
+    imgElement.crossOrigin = 'anonymous'
+
+    await new Promise<void>((resolve, reject) => {
+      imgElement.onload = () => resolve()
+      imgElement.onerror = (err) => reject(err)
+      imgElement.src = imageUrl
+    })
+
+    // canvas 可能在等待期间被销毁
+    if (!canvas.value) return
+
+    // 计算适合画布的尺寸
+    const canvasWidth = canvas.value.width!
+    const canvasHeight = canvas.value.height!
+    const imgWidth = imgElement.naturalWidth || imgElement.width
+    const imgHeight = imgElement.naturalHeight || imgElement.height
+
+    if (!imgWidth || !imgHeight) {
+      throw new Error('图片尺寸无效')
+    }
+
+    // 计算等比缩放比例
+    const scale = Math.min(
+      canvasWidth / imgWidth,
+      canvasHeight / imgHeight,
+    )
+
+    const scaledWidth = imgWidth * scale
+    const scaledHeight = imgHeight * scale
+
+    // Fabric v7:直接 new fabric.Image,然后用属性赋值替代 setBackgroundImage 回调
+    const fabricImage = new fabric.Image(imgElement, {
+      left: (canvasWidth - scaledWidth) / 2,
+      top: (canvasHeight - scaledHeight) / 2,
+      scaleX: scale,
+      scaleY: scale,
+      selectable: false,
+      evented: false,
+      originX: 'left',
+      originY: 'top',
+    })
+
+    canvas.value.backgroundImage = fabricImage
+    canvas.value.requestRenderAll()
+
+    // 重置历史记录
+    historyStack.value = []
+    historyIndex.value = -1
+    updateUndoRedoState()
+
+    // 初始保存历史
+    saveHistory()
+
+    showNotify({ type: 'success', message: '图片加载成功' })
+  } catch (error) {
+    console.error('图片加载失败:', error)
+    showNotify({ type: 'danger', message: '图片加载失败,请重试' })
+  }
+}
+
+// 选择工具
+function selectTool(tool: ToolMode) {
+  currentTool.value = tool
+  showPropertyPanel.value = tool !== 'select' && tool !== 'pan'
+  
+  if (!canvas.value) return
+
+  // 切换工具前移除旧的绘图鼠标事件监听,防止多次调用导致监听器堆叠
+  canvas.value.off('mouse:down')
+  canvas.value.off('mouse:move')
+  canvas.value.off('mouse:up')
+
+  // 根据工具设置画布模式
+  switch (tool) {
+    case 'select':
+      canvas.value.isDrawingMode = false
+      canvas.value.selection = true
+      canvas.value.defaultCursor = 'default'
+      break
+      
+    case 'pan':
+      canvas.value.isDrawingMode = false
+      canvas.value.selection = false
+      canvas.value.defaultCursor = 'grab'
+      setupPanTool()
+      break
+    case 'rect':
+      canvas.value.isDrawingMode = false
+      canvas.value.selection = false
+      canvas.value.defaultCursor = 'crosshair'
+      setupRectTool()
+      break
+      
+    case 'circle':
+      canvas.value.isDrawingMode = false
+      canvas.value.selection = false
+      canvas.value.defaultCursor = 'crosshair'
+      setupCircleTool()
+      break
+      
+    case 'arrow':
+      canvas.value.isDrawingMode = false
+      canvas.value.selection = false
+      canvas.value.defaultCursor = 'crosshair'
+      setupArrowTool()
+      break
+      
+    case 'text':
+      canvas.value.isDrawingMode = false
+      canvas.value.selection = false
+      canvas.value.defaultCursor = 'text'
+      setupTextTool()
+      break
+  }
+}
+
+// 设置平移工具
+function setupPanTool() {
+  if (!canvas.value) return
+
+  isPanning.value = false
+  
+  // 鼠标/触摸按下事件
+  canvas.value.on('mouse:down', (options) => {
+    if (currentTool.value !== 'pan') return
+    
+    isPanning.value = true
+    const pointer = options.scenePoint
+    panStartX = pointer.x
+    panStartY = pointer.y
+    canvas.value!.defaultCursor = 'grabbing'
+  })
+
+  // 鼠标/触摸移动事件
+  canvas.value.on('mouse:move', (options) => {
+    if (!isPanning.value || currentTool.value !== 'pan') return
+    
+    const pointer = options.scenePoint
+    const deltaX = pointer.x - panStartX
+    const deltaY = pointer.y - panStartY
+    
+    // 移动所有对象
+    canvas.value!.getObjects().forEach((obj) => {
+      obj.left! += deltaX
+      obj.top! += deltaY
+      obj.setCoords()
+    })
+
+    // 移动背景图片
+    const bgImage = canvas.value!.backgroundImage
+    if (bgImage) {
+      bgImage.left! += deltaX
+      bgImage.top! += deltaY
+    }
+
+    panStartX = pointer.x
+    panStartY = pointer.y
+    canvas.value!.renderAll()
+  })
+
+  // 鼠标/触摸释放事件
+  canvas.value.on('mouse:up', () => {
+    if (currentTool.value !== 'pan') return
+
+    isPanning.value = false
+    canvas.value!.defaultCursor = 'grab'
+  })
+}
+
+
+// 设置缩放功能
+function setupZoom() {
+  if (!canvas.value) return
+
+  // 鼠标滚轮缩放
+  canvas.value.on('mouse:wheel', (options) => {
+    const delta = options.e.deltaY
+    let zoom = canvas.value!.getZoom()
+    
+    // 计算新的缩放级别
+    zoom *= 0.999 ** delta
+    zoom = Math.max(minZoom, Math.min(maxZoom, zoom))
+    
+    // 应用缩放
+    canvas.value!.zoomToPoint(
+      { x: options.e.offsetX, y: options.e.offsetY },
+      zoom
+    )
+    
+    zoomLevel.value = zoom
+    options.e.preventDefault()
+    options.e.stopPropagation()
+  })
+
+  // 触摸缩放(双指捏合)
+  const canvasElement = canvas.value.getElement()
+  let lastDistance = 0
+  
+  canvasElement.addEventListener('touchstart', (e: TouchEvent) => {
+    if (e.touches.length === 2) {
+      lastDistance = Math.hypot(
+        e.touches[0].clientX - e.touches[1].clientX,
+        e.touches[0].clientY - e.touches[1].clientY
+      )
+    }
+  })
+  
+  canvasElement.addEventListener('touchmove', (e: TouchEvent) => {
+    if (e.touches.length === 2 && currentTool.value === 'pan') {
+      e.preventDefault()
+      
+      const currentDistance = Math.hypot(
+        e.touches[0].clientX - e.touches[1].clientX,
+        e.touches[0].clientY - e.touches[1].clientY
+      )
+      
+      if (lastDistance > 0) {
+        const zoom = canvas.value!.getZoom() * (currentDistance / lastDistance)
+        const centerX = (e.touches[0].clientX + e.touches[1].clientX) / 2
+        const centerY = (e.touches[0].clientY + e.touches[1].clientY) / 2
+        
+        const boundedZoom = Math.max(minZoom, Math.min(maxZoom, zoom))
+        canvas.value!.zoomToPoint(
+          { x: centerX, y: centerY },
+          boundedZoom
+        )
+        
+        zoomLevel.value = boundedZoom
+      }
+      
+      lastDistance = currentDistance
+    }
+  })
+  
+  canvasElement.addEventListener('touchend', () => {
+    lastDistance = 0
+  })
+}
+
+// 设置矩形工具
+function setupRectTool() {
+  if (!canvas.value) return
+
+  let rect: fabric.Rect | null = null
+  let isDrawing = false
+  let startX = 0
+  let startY = 0
+
+  canvas.value.on('mouse:down', (options) => {
+    if (currentTool.value !== 'rect') return;
+    // 如果点在已有对象上,不绘制新图形
+    if (options.target) return;
+
+    isDrawing = true;
+    const pointer = options.scenePoint;
+    startX = pointer.x;
+    startY = pointer.y;
+
+    rect = new fabric.Rect({
+      left: startX,
+      top: startY,
+      width: 0,
+      height: 0,
+      // 绘制中用半透明填充提供视觉反馈,mouseup 后改为透明
+      fill: 'rgba(255,255,255,0.15)',
+      stroke: currentColor.value,
+      strokeWidth: strokeWidth.value,
+      selectable: false,
+      evented: false,
+    });
+
+    canvas.value!.add(rect);
+  });
+
+  canvas.value.on('mouse:move', (options) => {
+    if (!isDrawing || !rect || currentTool.value !== 'rect') return
+
+    const pointer = options.scenePoint
+    const rawW = pointer.x - startX
+    const rawH = pointer.y - startY
+
+    rect.set({
+      width: Math.abs(rawW),
+      height: Math.abs(rawH),
+      left: rawW >= 0 ? startX : pointer.x,
+      top: rawH >= 0 ? startY : pointer.y,
+    })
+
+    canvas.value!.renderAll()
+  })
+
+  canvas.value.on('mouse:up', () => {
+    if (currentTool.value !== 'rect') return
+
+    isDrawing = false
+    if (rect) {
+      if (rect.width! < 5 || rect.height! < 5) {
+        // 太小则移除
+        canvas.value!.remove(rect)
+      } else {
+        // 绘制完成:去除半透明填充,恢复为可选中状态
+        rect.set({
+          fill: 'transparent',
+          selectable: true,
+          evented: true,
+        })
+        rect.setCoords()
+        canvas.value!.renderAll()
+      }
+    }
+    rect = null
+  })
+}
+
+// 设置圆形工具
+function setupCircleTool() {
+  if (!canvas.value) return
+
+  let circle: fabric.Circle | null = null
+  let isDrawing = false
+  let startX = 0
+  let startY = 0
+
+  canvas.value.on('mouse:down', (options) => {
+    if (currentTool.value !== 'circle') return;
+    // 如果点在已有对象上,不绘制新图形
+    if (options.target) return;
+
+    isDrawing = true;
+    const pointer = options.scenePoint;
+    startX = pointer.x;
+    startY = pointer.y;
+
+    circle = new fabric.Circle({
+      left: startX,
+      top: startY,
+      radius: 0,
+      // 绘制中用半透明填充提供视觉反馈,mouseup 后改为透明
+      fill: 'rgba(255,255,255,0.15)',
+      stroke: currentColor.value,
+      strokeWidth: strokeWidth.value,
+      selectable: false,
+      evented: false,
+    });
+
+    canvas.value!.add(circle);
+  });
+
+  canvas.value.on('mouse:move', (options) => {
+    if (!isDrawing || !circle || currentTool.value !== 'circle') return
+
+    const pointer = options.scenePoint
+    const rawW = pointer.x - startX
+    const rawH = pointer.y - startY
+    // 取宽高最小值确保始终是圆形(内切于拖拽包围盒)
+    const size = Math.min(Math.abs(rawW), Math.abs(rawH))
+    const radius = size / 2
+
+    circle.set({
+      radius,
+      left: rawW >= 0 ? startX : startX - size,
+      top: rawH >= 0 ? startY : startY - size,
+    })
+
+    canvas.value!.renderAll()
+  })
+
+  canvas.value.on('mouse:up', () => {
+    if (currentTool.value !== 'circle') return
+
+    isDrawing = false
+    if (circle) {
+      if (circle.radius! < 5) {
+        // 太小则移除
+        canvas.value!.remove(circle)
+      } else {
+        // 绘制完成:去除半透明填充,恢复为可选中状态
+        circle.set({
+          fill: 'transparent',
+          selectable: true,
+          evented: true,
+        })
+        circle.setCoords()
+        canvas.value!.renderAll()
+      }
+    }
+    circle = null
+  })
+}
+
+// 设置箭头工具
+function setupArrowTool() {
+  if (!canvas.value) return
+
+  let line: fabric.Line | null = null
+  let arrowHead1: fabric.Line | null = null
+  let arrowHead2: fabric.Line | null = null
+  let isDrawing = false
+  let startX = 0
+  let startY = 0
+
+  canvas.value.on('mouse:down', (options) => {
+    if (currentTool.value !== 'arrow') return
+    // 如果点在已有对象上,不绘制新图形
+    if (options.target) return
+
+    isDrawing = true
+    const pointer = options.scenePoint
+    startX = pointer.x
+    startY = pointer.y
+
+    // 直接将三条线加到画布(避免在 Group 内使用绝对坐标的问题)
+    line = new fabric.Line([startX, startY, startX, startY], {
+      stroke: currentColor.value,
+      strokeWidth: strokeWidth.value,
+      selectable: false,
+      evented: false,
+    })
+    arrowHead1 = new fabric.Line([startX, startY, startX, startY], {
+      stroke: currentColor.value,
+      strokeWidth: strokeWidth.value,
+      selectable: false,
+      evented: false,
+    })
+    arrowHead2 = new fabric.Line([startX, startY, startX, startY], {
+      stroke: currentColor.value,
+      strokeWidth: strokeWidth.value,
+      selectable: false,
+      evented: false,
+    })
+
+    canvas.value!.add(line, arrowHead1, arrowHead2)
+  })
+
+  canvas.value.on('mouse:move', (options) => {
+    if (!isDrawing || !line || !arrowHead1 || !arrowHead2 || currentTool.value !== 'arrow') return
+
+    const pointer = options.scenePoint
+
+    // 更新主线终点
+    line.set({ x2: pointer.x, y2: pointer.y })
+
+    // 计算箭头角度和头部坐标
+    const dx = pointer.x - startX
+    const dy = pointer.y - startY
+    const angle = Math.atan2(dy, dx)
+    const length = Math.sqrt(dx * dx + dy * dy)
+
+    if (length > 0) {
+      const arrowHeadSize = SHAPE_CONFIG.ARROW_HEAD_SIZE
+      const arrowAngle = Math.PI / 6 // 30度
+
+      // 箭头头部点1
+      const angle1 = angle + arrowAngle
+      const hx1 = pointer.x - arrowHeadSize * Math.cos(angle1)
+      const hy1 = pointer.y - arrowHeadSize * Math.sin(angle1)
+
+      // 箭头头部点2
+      const angle2 = angle - arrowAngle
+      const hx2 = pointer.x - arrowHeadSize * Math.cos(angle2)
+      const hy2 = pointer.y - arrowHeadSize * Math.sin(angle2)
+
+      arrowHead1.set({ x1: pointer.x, y1: pointer.y, x2: hx1, y2: hy1 })
+      arrowHead2.set({ x1: pointer.x, y1: pointer.y, x2: hx2, y2: hy2 })
+    }
+
+    canvas.value!.renderAll()
+  })
+
+  canvas.value.on('mouse:up', () => {
+    if (currentTool.value !== 'arrow') return
+
+    isDrawing = false
+    if (line && arrowHead1 && arrowHead2) {
+      const dx = line.x2! - line.x1!
+      const dy = line.y2! - line.y1!
+      const length = Math.sqrt(dx * dx + dy * dy)
+
+      if (length < 10) {
+        // 太短则移除
+        canvas.value!.remove(line, arrowHead1, arrowHead2)
+      } else {
+        // 从画布移除后组合成 Group,使箭头可作为整体被选中/移动
+        canvas.value!.remove(line, arrowHead1, arrowHead2)
+        const group = new fabric.Group([line, arrowHead1, arrowHead2], {
+          selectable: true,
+          evented: true,
+        })
+        canvas.value!.add(group)
+        canvas.value!.renderAll()
+      }
+    }
+
+    line = null
+    arrowHead1 = null
+    arrowHead2 = null
+  })
+}
+
+// 设置文字工具
+function setupTextTool() {
+  if (!canvas.value) return
+
+  canvas.value.on('mouse:down', (options) => {
+    if (currentTool.value !== 'text') return
+    
+    const pointer = options.scenePoint
+    
+    const text = new fabric.IText('点击编辑文字', {
+      left: pointer.x,
+      top: pointer.y,
+      fontSize: fontSize.value,
+      fill: currentColor.value,
+      backgroundColor: 'transparent',
+      padding: 8,
+      selectable: true,
+      editable: true,
+    })
+    
+    canvas.value!.add(text)
+    canvas.value!.setActiveObject(text)
+    
+    // 进入编辑模式
+    nextTick(() => {
+      text.enterEditing()
+      text.selectAll()
+    })
+  })
+}
+
+// 更新属性面板
+function updatePropertyPanel() {
+  if (!canvas.value) return
+  
+  const activeObject = canvas.value.getActiveObject()
+  if (activeObject) {
+    // 更新当前颜色和线条粗细
+    if (activeObject.stroke) {
+      currentColor.value = activeObject.stroke as string
+    }
+    if (activeObject.strokeWidth) {
+      strokeWidth.value = activeObject.strokeWidth
+    }
+    
+    // 更新字体大小(如果是文本对象)
+    if (activeObject.type === 'i-text' || activeObject.type === 'textbox') {
+      const textObj = activeObject as fabric.IText
+      if (textObj.fontSize) {
+        fontSize.value = textObj.fontSize
+      }
+    }
+  }
+}
+
+// 保存历史记录
+function saveHistory() {
+  if (!canvas.value || isRestoring) return
+  
+  const json = JSON.stringify(canvas.value.toJSON())
+  
+  // 截断重做部分
+  if (historyIndex.value < historyStack.value.length - 1) {
+    historyStack.value = historyStack.value.slice(0, historyIndex.value + 1)
+  }
+  
+  historyStack.value.push(json)
+
+  // 超出历史记录上限时,移除最旧的记录,防止内存占用无限增长
+  if (historyStack.value.length > HISTORY_CONFIG.MAX_HISTORY) {
+    historyStack.value.shift()
+  }
+
+  historyIndex.value = historyStack.value.length - 1
+  updateUndoRedoState()
+}
+
+// 更新撤销/重做状态
+function updateUndoRedoState() {
+  canUndo.value = historyIndex.value > 0
+  canRedo.value = historyIndex.value < historyStack.value.length - 1
+}
+
+// 撤销操作
+async function handleUndo() {
+  if (!canvas.value || !canUndo.value) return
+
+  isRestoring = true
+  historyIndex.value--
+  const json = historyStack.value[historyIndex.value]
+  try {
+    await canvas.value.loadFromJSON(json)
+    canvas.value.renderAll()
+  } finally {
+    isRestoring = false
+  }
+  updateUndoRedoState()
+}
+
+// 重做操作
+async function handleRedo() {
+  if (!canvas.value || !canRedo.value) return
+
+  isRestoring = true
+  historyIndex.value++
+  const json = historyStack.value[historyIndex.value]
+  try {
+    await canvas.value.loadFromJSON(json)
+    canvas.value.renderAll()
+  } finally {
+    isRestoring = false
+  }
+  updateUndoRedoState()
+}
+
+// 重置画布
+function handleReset() {
+  if (!canvas.value || !currentImage.value) return
+
+  const imageSource =
+    currentImage.value.url ||
+    currentImage.value.content ||
+    (currentImage.value.file instanceof File ? URL.createObjectURL(currentImage.value.file) : null)
+
+  if (!imageSource) return
+  loadImageToCanvas(imageSource)
+  showNotify({ type: 'primary', message: '已重置' })
+}
+
+// 保存图片
+async function handleSave() {
+  if (!canvas.value || !currentImage.value) return
+
+  try {
+    // 隐藏所有控制点
+    canvas.value.discardActiveObject()
+
+    // 获取背景图及原图尺寸
+    const bgImg = canvas.value.backgroundImage as fabric.Image | null
+    if (!bgImg) throw new Error('未找到背景图片')
+
+    const imgWidth = bgImg.width!
+    const imgHeight = bgImg.height!
+    if (!imgWidth || !imgHeight) throw new Error('原图尺寸无效')
+
+    const bgScaleX = bgImg.scaleX ?? 1
+    const bgScaleY = bgImg.scaleY ?? 1
+    const bgLeft = bgImg.left ?? 0
+    const bgTop = bgImg.top ?? 0
+    const scaledW = imgWidth * bgScaleX
+    const scaledH = imgHeight * bgScaleY
+
+    // 临时重置 viewport(消除用户缩放/平移),保证渲染坐标与 scene 坐标一致
+    const savedVpt = [...canvas.value.viewportTransform!] as [number, number, number, number, number, number]
+    canvas.value.setViewportTransform([1, 0, 0, 1, 0, 0])
+    canvas.value.renderAll()
+
+    // 直接从 Fabric 画布 HTML 元素上裁剪图片区域,缩放到原图尺寸
+    // 这样完全避免手动坐标变换,所有标注由 Fabric 自己渲染,位置天然正确
+    const fabricEl = canvas.value.getElement() as HTMLCanvasElement
+    const tempCanvas = document.createElement('canvas')
+    tempCanvas.width = imgWidth
+    tempCanvas.height = imgHeight
+    const tempCtx = tempCanvas.getContext('2d')!
+
+    // Fabric 内部画布按 devicePixelRatio 放大实际分辨率,而 bgLeft/bgTop/scaledW/scaledH
+    // 均为 Fabric 的 CSS 坐标(逻辑像素),读取 fabricEl 时需换算成物理像素,
+    // 否则在高 DPR 设备(移动端 DPR=2/3)上裁剪区域偏移,导致输出白板。
+    const dpr = window.devicePixelRatio || 1
+    tempCtx.drawImage(
+      fabricEl,
+      bgLeft * dpr, bgTop * dpr, scaledW * dpr, scaledH * dpr,  // 物理像素坐标
+      0, 0, imgWidth, imgHeight,                                   // 缩放到原图尺寸输出
+    )
+
+    const dataURL = tempCanvas.toDataURL('image/png', 1)
+
+    // 恢复 viewport
+    canvas.value.setViewportTransform(savedVpt)
+    canvas.value.renderAll()
+
+    // 触发保存事件
+    emit('save-image', dataURL, currentType.value)
+    visible.value = false
+
+    showNotify({ type: 'success', message: '图片保存成功' })
+  } catch (error) {
+    console.error('保存失败:', error)
+    // 出现异常时也尝试恢复 viewport
+    if (canvas.value) canvas.value.renderAll()
+    showNotify({ type: 'danger', message: '保存失败,请重试' })
+  }
+}
+
+// 取消编辑
+function handleCancel() {
+  visible.value = false
+  emit('cancel')
+}
+
+// 添加移动端触摸事件优化
+let mobileTouchOptimized = false
+function setupMobileTouchOptimization() {
+  if (!canvas.value || mobileTouchOptimized) return
+  mobileTouchOptimized = true
+
+  const canvasElement = canvas.value.getElement()
+
+  // 阻止默认的触摸行为(如滚动)
+  canvasElement.addEventListener('touchstart', (e) => {
+    if (e.touches.length === 1 && currentTool.value === 'pan') {
+      e.preventDefault()
+    }
+  }, { passive: false })
+
+  // 长按提示
+  let longPressTimer: number | null = null
+
+  canvasElement.addEventListener('touchstart', (e) => {
+    if (e.touches.length === 1) {
+      longPressTimer = window.setTimeout(() => {
+        showNotify({ type: 'info', message: '长按可进入平移模式' })
+      }, MOBILE_LONGPRESS_DELAY)
+    }
+  })
+
+  canvasElement.addEventListener('touchend', () => {
+    if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null }
+  })
+
+  canvasElement.addEventListener('touchmove', () => {
+    if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null }
+  })
+}
+
+// 在 Canvas 初始化后调用移动端优化
+onMounted(() => {
+  if (canvasEl.value) {
+    initCanvas()
+    nextTick(() => {
+      setupMobileTouchOptimization()
+    })
+  }
+  // 在正确的生命周期中注册全局键盘快捷键(由 onUnmounted 统一清理)
+  window.addEventListener('keydown', handleKeyDown)
+})
+
+</script>
+
+<style scoped lang="sass">
+.edit-image-container
+  display: flex
+  flex-direction: column
+  height: 100vh
+  background: #f0f0f0
+  overflow: hidden
+
+// ── 顶部标题栏 ──────────────────────────
+.header
+  display: flex
+  justify-content: space-between
+  align-items: center
+  padding: 0 12px
+  height: 50px
+  background: #fff
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12)
+  flex-shrink: 0
+  z-index: 10
+
+  .title
+    font-size: 17px
+    font-weight: 600
+    color: #1a1a1a
+
+  .actions
+    display: flex
+    gap: 8px
+    align-items: center
+
+    .van-button
+      min-width: 62px
+      height: 32px
+      border-radius: 16px
+      font-size: 14px
+
+// ── Canvas 区域 ──────────────────────────
+.canvas-container
+  flex: 1
+  overflow: hidden
+  display: flex
+  align-items: stretch
+  justify-content: stretch
+  background: #7a7a7a
+  position: relative
+
+  // canvas 元素和 Fabric 创建的 .upper-canvas 都充满容器
+  :deep(canvas),
+  :deep(.upper-canvas)
+    display: block !important
+    width: 100% !important
+    height: 100% !important
+
+// ── 底部工具栏 ──────────────────────────
+.toolbar
+  background: #fff
+  padding: 10px 12px 8px
+  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08)
+  flex-shrink: 0
+
+  .tool-group
+    display: flex
+    gap: 6px
+    justify-content: center
+    margin-bottom: 10px
+    flex-wrap: wrap
+
+    .tool-btn
+      display: flex
+      flex-direction: column
+      align-items: center
+      justify-content: center
+      padding: 7px 10px 5px
+      border: none
+      background: #f7f7f7
+      border-radius: 10px
+      cursor: pointer
+      transition: background 0.15s, color 0.15s
+      min-width: 56px
+      line-height: 1
+
+      &:active
+        background: #e8e8e8
+
+      &.active
+        background: #1989fa
+
+        .tool-icon,
+        .tool-text-icon
+          color: #fff !important
+
+        .tool-label
+          color: #fff
+
+      .tool-icon
+        font-size: 22px
+        color: #f0943a
+        margin-bottom: 4px
+        display: flex
+        align-items: center
+        justify-content: center
+
+      .tool-text-icon
+        font-size: 20px
+        font-weight: 700
+        color: #f0943a
+        margin-bottom: 4px
+        height: 22px
+        line-height: 22px
+
+      .tool-label
+        font-size: 11px
+        color: #555
+        white-space: nowrap
+
+  // ── 操作按钮行 ──
+  .action-group
+    display: flex
+    gap: 8px
+    align-items: center
+
+    .action-btn
+      flex: 1
+      height: 36px
+      border-radius: 8px
+      font-size: 14px
+      border-color: #ddd
+      color: #555
+
+      &:disabled
+        opacity: 0.4
+
+    .settings-btn
+      flex-shrink: 0
+      width: 36px
+      height: 36px
+      padding: 0
+      border-radius: 50%
+      border-color: #ddd
+      color: #666
+      display: flex
+      align-items: center
+      justify-content: center
+      font-size: 18px
+
+// ── 属性面板 ──────────────────────────
+.property-panel
+  background: #fff
+  padding: 10px 14px
+  border-top: 1px solid #ebebeb
+  flex-shrink: 0
+
+  .property-group
+    display: flex
+    flex-direction: column
+    gap: 12px
+
+  .property-item
+    display: flex
+    align-items: center
+    gap: 10px
+
+    .property-label
+      font-size: 13px
+      color: #444
+      min-width: 52px
+      flex-shrink: 0
+
+    .color-picker
+      display: flex
+      gap: 8px
+      flex-wrap: wrap
+
+      .color-option
+        width: 22px
+        height: 22px
+        border-radius: 50%
+        cursor: pointer
+        border: 2px solid transparent
+        transition: transform 0.15s
+        box-shadow: 0 1px 3px rgba(0,0,0,0.2)
+
+        &:active
+          transform: scale(1.15)
+
+        &.active
+          border-color: #1a1a1a
+          transform: scale(1.15)
+
+    .van-slider
+      flex: 1
+
+    .slider-value
+      font-size: 13px
+      color: #888
+      min-width: 38px
+      text-align: right
+      flex-shrink: 0
+
+// ── 移动端适配 ──────────────────────────
+@media (max-width: 480px)
+  .toolbar
+    .tool-group
+      .tool-btn
+        min-width: 50px
+        padding: 6px 8px 4px
+
+        .tool-icon
+          font-size: 20px
+
+        .tool-label
+          font-size: 10px
+
+  .property-item
+    flex-wrap: wrap
+
+    .property-label
+      min-width: auto !important
+
+    .color-picker
+      justify-content: flex-start
+</style>

+ 156 - 0
src/composables/useImageUpload.ts

@@ -0,0 +1,156 @@
+import { ref, type Ref } from 'vue'
+import type { UploadImage, UPLOAD_STATUS } from '@/types/upload'
+import { detailImageUpload } from '@/api/returned/index.ts'
+import { compressImage } from '@/utils/imageCompression'
+
+type ToastFn = (arg: any) => void
+
+export function useImageUpload(opts: {
+  outerImages: Ref<UploadImage[]>
+  innerImages: Ref<UploadImage[]>
+  showFailToast?: ToastFn
+  showNotify?: ToastFn
+  showLoadingToast?: ToastFn
+  closeToast?: ToastFn
+}) {
+  const { outerImages, innerImages } = opts
+
+  const beforeReadImage = (file: File | File[] | any): boolean => {
+    const files: File[] = Array.isArray(file) ? file : [file]
+    for (const f of files) {
+      const name = f?.name?.toLowerCase?.() ?? ''
+      if (name.endsWith('.heic') || name.endsWith('.heif')) {
+        opts.showFailToast?.('不支持的图片格式')
+        return false
+      }
+      const isImage = /^image\//.test(f?.type)
+      if (!isImage) {
+        opts.showFailToast?.('仅支持图片文件')
+        return false
+      }
+      if (f?.size && f.size > 15 * 1024 * 1024) {
+        opts.showFailToast?.('图片大小不能超过15MB')
+        return false
+      }
+    }
+
+    // 文件校验通过,由调用方通过 v-model 将文件推入对应列表
+    return true
+  }
+
+  const uploadSingleImage = async (
+    imageObj: UploadImage,
+    category: string,
+  ): Promise<boolean> => {
+    imageObj.status = UPLOAD_STATUS.UPLOADING
+    try {
+      const compressedFile = await compressImage(imageObj.file, 0.5)
+      const data = new FormData()
+      data.set('file', compressedFile)
+      data.set('type', category)
+      await detailImageUpload(data)
+      imageObj.status = UPLOAD_STATUS.SUCCESS
+      // 使用服务端返回的 URL;若仅有本地 File,则创建临时 Blob URL
+      // 注意:调用方应在图片删除时调用 URL.revokeObjectURL(imageObj.url) 释放内存
+      imageObj.url = URL.createObjectURL(imageObj.file)
+      imageObj.error = null
+      return true
+    } catch (error) {
+      imageObj.status = UPLOAD_STATUS.FAILED
+      imageObj.error = '上传失败,请稍后再试'
+      // 初次失败不修改 retryCount,确保重传资格判断正确(retryCount 在重传函数中累加)
+      console.error('uploadSingleImage error', error)
+      return false
+    }
+  }
+
+  const uploadCategoryWithStatus = async (
+    list: UploadImage[],
+    categoryName: string,
+  ): Promise<{ success: boolean; item: UploadImage }[]> => {
+    const results: { success: boolean; item: UploadImage }[] = []
+    for (const item of list) {
+      if (item.status === UPLOAD_STATUS.SUCCESS) {
+        results.push({ success: true, item })
+        continue
+      }
+      const success = await uploadSingleImage(item, categoryName)
+      results.push({ success, item })
+    }
+    return results
+  }
+
+  const hasFailedImages = (): boolean => {
+    const all = [...outerImages.value, ...innerImages.value] as UploadImage[]
+    return all.some(
+      (img) => img.status === UPLOAD_STATUS.FAILED && img.retryCount < 1,
+    )
+  }
+
+  const retrySingleImage = async (
+    imageObj: UploadImage,
+    category: string,
+  ): Promise<void> => {
+    if (imageObj.retryCount >= 1) {
+      opts.showNotify?.({ type: 'warning', message: '已达到最大重传次数' })
+      return
+    }
+    imageObj.status = UPLOAD_STATUS.RETRYING
+    imageObj.retryCount += 1 // 重传时才累加计数器
+    const success = await uploadSingleImage(imageObj, category)
+    if (success) {
+      opts.showNotify?.({ type: 'success', message: '重传成功' })
+    } else {
+      opts.showNotify?.({ type: 'danger', message: '重传失败' })
+    }
+  }
+
+  const retryAllFailedImages = async (): Promise<void> => {
+    const all = [...outerImages.value, ...innerImages.value]
+    const failedImages = all.filter(
+      (img) => img.status === UPLOAD_STATUS.FAILED && img.retryCount < 1,
+    )
+    if (failedImages.length === 0) {
+      opts.showNotify?.({ type: 'info', message: '没有需要重传的图片' })
+      return
+    }
+    const toast = opts.showLoadingToast?.({
+      duration: 0,
+      message: `重传 ${failedImages.length} 张图片...`,
+    })
+    let successCount = 0
+    for (const img of failedImages) {
+      const category = outerImages.value.includes(img)
+        ? 'RETURNED_BOX_IMAGE'
+        : 'RETURNED_INNER_IMAGE'
+      img.retryCount += 1 // 标记已重传,防止重复重传
+      const ok = await uploadSingleImage(img, category)
+      if (ok) successCount++
+    }
+    // close toast if opened
+    opts.closeToast?.()
+    opts.showNotify?.({
+      type: successCount === failedImages.length ? 'success' : 'warning',
+      message: `重传完成,成功 ${successCount}/${failedImages.length} 张`,
+    })
+  }
+
+  const onSubmit = async (): Promise<void> => {
+    // Re-exported for compatibility when used by main component; actual submission logic lives in component
+    // This placeholder is kept to preserve the same API surface
+    // The real submission logic is implemented in the main component using uploadCategoryWithStatus
+  }
+
+  // Expose helpers for the component to use
+  return {
+    beforeReadImage,
+    uploadSingleImage,
+    uploadCategoryWithStatus,
+    hasFailedImages,
+    retrySingleImage,
+    retryAllFailedImages,
+    outerImages,
+    innerImages,
+    onSubmit,
+  }
+}

+ 57 - 0
src/types/editImage.ts

@@ -0,0 +1,57 @@
+/**
+ * 图片编辑组件类型定义
+ * 中央类型管理,避免圆形依赖和代码重复
+ */
+
+/**
+ * 图片项接口
+ */
+export interface ImageItem {
+  /** 文件对象 */
+  file: File
+  /** 图片URL(上传成功后才有) */
+  url: string | null
+  /** van-uploader 读取文件后生成的 base64 预览内容 */
+  content?: string
+  /** 图片状态 */
+  status: string
+  /** 错误信息 */
+  error: string | null
+  /** 重试次数 */
+  retryCount: number
+  /** 原始文件 */
+  originalFile: File
+}
+
+/**
+ * EditImage 组件暴露的接口
+ */
+export interface EditImageExpose {
+  /**
+   * 编辑图片
+   * @param imageObj 图片对象
+   * @param type 图片类型 ('outer' | 'inner')
+   */
+  editImage: (imageObj: ImageItem, type: 'outer' | 'inner') => void
+}
+
+/**
+ * 工具模式类型
+ */
+export type ToolMode = 'select' | 'pan' | 'rect' | 'circle' | 'arrow' | 'text'
+
+/**
+ * 工具定义接口
+ */
+export interface ToolDefinition {
+  id: ToolMode
+  icon: string
+  label: string
+}
+
+/**
+ * 事件监听器映射
+ */
+export interface EventListenerMap {
+  [key: string]: (...args: any[]) => void
+}

+ 28 - 0
src/types/upload.ts

@@ -0,0 +1,28 @@
+// Upload related types moved to a centralized type file
+
+export enum UPLOAD_STATUS {
+  PENDING = 'pending',
+  UPLOADING = 'uploading',
+  SUCCESS = 'success',
+  FAILED = 'failed',
+  RETRYING = 'retrying',
+}
+
+export interface UploadImage {
+  file: File
+  status: UPLOAD_STATUS
+  url: string | null
+  error: string | null
+  retryCount: number
+  originalFile: File
+  content?: string
+}
+
+export interface Workbench {
+  warehouseCode: string
+  workStation: string
+}
+
+export interface EditImageExposed {
+  editImage: (image: UploadImage, type: 'outer' | 'inner') => void
+}

+ 162 - 0
src/utils/timerManager.ts

@@ -0,0 +1,162 @@
+/**
+ * 定时器集中管理类
+ *
+ * 用于管理 setTimeout 和 setInterval,
+ * 自动追踪所有定时器 ID,支持一次性清理所有定时器。
+ *
+ * 解决问题:
+ * - 防止定时器泄漏(组件卸载时自动清理)
+ * - 便于调试和性能监控
+ * - 避免重复清理导致的错误
+ *
+ * @example
+ * ```typescript
+ * const timerManager = new TimerManager()
+ *
+ * // 使用 setTimeout
+ * timerManager.setTimeout(() => {
+ *   console.log('1秒后执行')
+ * }, 1000)
+ *
+ * // 在卸载时清理所有定时器
+ * onUnmounted(() => {
+ *   timerManager.clearAll()
+ * })
+ * ```
+ */
+export class TimerManager {
+  /** 存储所有 setTimeout ID */
+  private timeoutIds: Set<ReturnType<typeof setTimeout>> = new Set()
+
+  /** 存储所有 setInterval ID */
+  private intervalIds: Set<ReturnType<typeof setInterval>> = new Set()
+
+  /**
+   * setTimeout 包装函数
+   *
+   * 自动追踪定时器 ID,执行后自动清理
+   *
+   * @param callback 执行的回调函数
+   * @param delay 延迟时间(毫秒)
+   * @returns 定时器 ID
+   */
+  setTimeout(
+    callback: () => void,
+    delay: number
+  ): ReturnType<typeof setTimeout> {
+    const id = window.setTimeout(() => {
+      try {
+        callback()
+      } finally {
+        // 执行完后自动移除
+        this.timeoutIds.delete(id)
+      }
+    }, delay)
+
+    this.timeoutIds.add(id)
+    return id
+  }
+
+  /**
+   * setInterval 包装函数
+   *
+   * @param callback 执行的回调函数
+   * @param interval 间隔时间(毫秒)
+   * @returns 定时器 ID
+   */
+  setInterval(
+    callback: () => void,
+    interval: number
+  ): ReturnType<typeof setInterval> {
+    const id = window.setInterval(callback, interval)
+    this.intervalIds.add(id)
+    return id
+  }
+
+  /**
+   * 清理指定的 setTimeout
+   *
+   * @param id 定时器 ID
+   */
+  clearTimeout(id: ReturnType<typeof setTimeout>): void {
+    if (this.timeoutIds.has(id)) {
+      window.clearTimeout(id)
+      this.timeoutIds.delete(id)
+    }
+  }
+
+  /**
+   * 清理指定的 setInterval
+   *
+   * @param id 定时器 ID
+   */
+  clearInterval(id: ReturnType<typeof setInterval>): void {
+    if (this.intervalIds.has(id)) {
+      window.clearInterval(id)
+      this.intervalIds.delete(id)
+    }
+  }
+
+  /**
+   * 清理所有定时器
+   *
+   * 建议在组件的 onUnmounted 钩子中调用
+   *
+   * @example
+   * ```typescript
+   * onUnmounted(() => {
+   *   timerManager.clearAll()
+   * })
+   * ```
+   */
+  clearAll(): void {
+    // 清理所有 setTimeout
+    for (const id of this.timeoutIds) {
+      window.clearTimeout(id)
+    }
+    this.timeoutIds.clear()
+
+    // 清理所有 setInterval
+    for (const id of this.intervalIds) {
+      window.clearInterval(id)
+    }
+    this.intervalIds.clear()
+  }
+
+  /**
+   * 获取当前的 setTimeout 数量
+   *
+   * 用于性能监控和调试
+   *
+   * @returns 当前未执行的 setTimeout 数量
+   */
+  getTimeoutCount(): number {
+    return this.timeoutIds.size
+  }
+
+  /**
+   * 获取当前的 setInterval 数量
+   *
+   * 用于性能监控和调试
+   *
+   * @returns 当前活跃的 setInterval 数量
+   */
+  getIntervalCount(): number {
+    return this.intervalIds.size
+  }
+
+  /**
+   * 获取定时器统计信息
+   *
+   * 用于调试和性能分析
+   *
+   * @returns 定时器统计对象
+   */
+  getStats(): { timeouts: number; intervals: number; total: number } {
+    return {
+      timeouts: this.timeoutIds.size,
+      intervals: this.intervalIds.size,
+      total: this.timeoutIds.size + this.intervalIds.size,
+    }
+  }
+}