Parcourir la source

Merge branch 'zengjun/退货/20260123退货拍照优化' into testing

# Conflicts:
#	package.json
zengjun il y a 1 mois
Parent
commit
a291a926b5

+ 2 - 0
package.json

@@ -14,7 +14,9 @@
   },
   "dependencies": {
     "@ericblade/quagga2": "^1.12.1",
+    "@types/fabric": "^5.3.11",
     "axios": "^1.7.9",
+    "fabric": "^7.1.0",
     "lib-flexible": "^0.3.2",
     "pinia": "^2.3.0",
     "pinia-plugin-persistedstate": "^3.2.1",

+ 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/jpeg', 0.92)
+
+    // 恢复 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,
+  }
+}

+ 98 - 0
src/constants/editImageConfig.ts

@@ -0,0 +1,98 @@
+/**
+ * EditImage 组件配置常量
+ * 统一管理所有"魔数",便于维护和配置调整
+ */
+
+/**
+ * 缩放配置
+ */
+export const ZOOM_CONFIG = {
+  /** 最小缩放级别,防止过度缩小 */
+  MIN_ZOOM: 0.1,
+  /** 最大缩放级别,防止过度放大导致性能问题 */
+  MAX_ZOOM: 5,
+} as const
+
+/**
+ * 历史记录配置
+ */
+export const HISTORY_CONFIG = {
+  /** 历史记录最大项数,超过此数后移除最旧的记录 */
+  MAX_HISTORY: 50,
+} as const
+
+/**
+ * 形状绘制配置
+ */
+export const SHAPE_CONFIG = {
+  /** 箭头头部大小(像素) */
+  ARROW_HEAD_SIZE: 15,
+  /** 最小形状尺寸(像素),小于此值的形状会被丢弃 */
+  MIN_SHAPE_SIZE: 5,
+  /** 最小箭头长度(像素) */
+  MIN_ARROW_LENGTH: 10,
+  /** 虚线间距数组 */
+  DASH_ARRAY: [5, 5],
+} as const
+
+/**
+ * 文本配置
+ */
+export const TEXT_CONFIG = {
+  /** 文本编辑模式进入延迟(毫秒),确保点击可以进入编辑模式 */
+  ENTER_EDIT_DELAY: 100,
+  /** 默认文本占位符 */
+  DEFAULT_PLACEHOLDER: '点击编辑文字',
+  /** 文本背景透明度(0-1) */
+  BG_OPACITY: 0.8,
+  /** 文本默认填充(像素) */
+  DEFAULT_PADDING: 8,
+} as const
+
+/**
+ * 滑块范围配置
+ */
+export const SLIDER_RANGES = {
+  /** 线条粗细范围 */
+  strokeWidth: {
+    min: 1,
+    max: 10,
+    step: 1,
+  },
+  /** 字体大小范围 */
+  fontSize: {
+    min: 8,
+    max: 72,
+    step: 2,
+  },
+} as const
+
+/**
+ * 颜色列表
+ * 按业务语义组织颜色,便于用户选择
+ */
+export const COLOR_LIST = [
+  '#ff3b30', // 红色 - 用于标记问题
+  '#007aff', // 蓝色 - 用于标记注意
+  '#34c759', // 绿色 - 用于标记正常
+  '#ff9500', // 橙色 - 用于标记警告
+  '#5856d6', // 紫色 - 用于标记其他
+  '#000000', // 黑色
+] as const
+
+/**
+ * 移动端长按提示延迟(毫秒)
+ */
+export const MOBILE_LONGPRESS_DELAY = 1000
+
+/**
+ * Canvas 容器样式配置
+ */
+export const CANVAS_CONFIG = {
+  /** Canvas 背景颜色 */
+  BACKGROUND_COLOR: '#f5f5f5',
+  /** Canvas 容器背景颜色 */
+  CONTAINER_BACKGROUND: '#e5e5e5',
+  /** 图片最大缩放比例,避免超过原始尺寸 */
+  IMAGE_MAX_SCALE: 1,
+} as const

+ 1 - 1
src/static/setting.txt

@@ -1 +1 @@
-80
+93

+ 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,
+    }
+  }
+}

+ 636 - 105
src/views/returned/register/index.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="container">
     <van-nav-bar
-      title="退货登记"
+      title="图片编辑"
       left-arrow
       @click-left="goBack"
       @click-right="onReset"
@@ -16,20 +16,26 @@
     </van-nav-bar>
 
     <div class="init-container">
-      <div style="background-color: white">
-        <p style="margin: 3px;font-size: 14px">
-          仓库:<span style="color: #333333">{{ workbench.warehouseCode}}</span>
+      <div class="workbench-info">
+        <p class="info-line">
+          仓库:<span class="info-value">{{ workbench.warehouseCode }}</span>
         </p>
-        <p style="margin: 3px;font-size: 14px">
-          工作台:<span style="color: #333333">{{ workbench.workStation}}</span>
+        <p class="info-line">
+          工作台:<span class="info-value">{{ workbench.workStation }}</span>
         </p>
-        <p style="margin: 3px;font-size: 14px">支持png/jpeg/jpg/webp</p>
-
-        <van-button size="mini" type="primary" plain icon="replay" @click.stop="getWorkbench"></van-button>
+        <p class="info-line hint-text">支持png/jpeg/jpg/webp</p>
+        <van-button
+          size="mini"
+          type="primary"
+          plain
+          icon="replay"
+          class="refresh-btn"
+          @click.stop="getWorkbench"
+        ></van-button>
       </div>
       <div class="scan-returned-content">
         <div class="input-group">
-          <van-cell title="外箱图上传"  />
+          <van-cell title="外箱图上传" />
           <van-uploader
             v-model="outerImages"
             :max-count="5"
@@ -37,11 +43,43 @@
             :before-read="beforeReadImage"
             :preview-full-image="true"
             :deletable="true"
-          />
+          >
+            <template #preview-cover="{ file, index }">
+              <div class="custom-cover">
+                <!-- 状态指示器 -->
+                <div class="upload-status" :class="file.status">
+                  <span v-if="file.status === 'uploading'">上传中...</span>
+                  <span v-else-if="file.status === 'retrying'">重试中...</span>
+                  <span v-else-if="file.status === 'success'">✓ 成功</span>
+                  <span v-else-if="file.status === 'failed'">
+                    ✗ 失败
+                    <van-button
+                      v-if="file.retryCount < 1"
+                      size="mini"
+                      @click.stop="retrySingleImage(file, 'RETURNED_BOX_IMAGE')"
+                      style="margin-left: 5px; background: #fff; color: #ee0a24"
+                    >
+                      重试
+                    </van-button>
+                    <span v-else style="margin-left: 5px">(已达上限)</span>
+                  </span>
+                </div>
+
+                <van-button
+                  class="cover-button"
+                  size="mini"
+                  icon="edit"
+                  @click.stop="handleOuterImages(file, index)"
+                >
+                  编辑
+                </van-button>
+              </div>
+            </template>
+          </van-uploader>
         </div>
 
         <div class="input-group">
-          <van-cell title="内物图上传"  />
+          <van-cell title="内物图上传" />
           <van-uploader
             v-model="innerImages"
             :max-count="5"
@@ -49,65 +87,187 @@
             :before-read="beforeReadImage"
             :preview-full-image="true"
             :deletable="true"
-          />
+          >
+            <template #preview-cover="{ file, index }">
+              <div class="custom-cover">
+                <!-- 状态指示器 -->
+                <div class="upload-status" :class="file.status">
+                  <span v-if="file.status === 'uploading'">上传中...</span>
+                  <span v-else-if="file.status === 'retrying'">重试中...</span>
+                  <span v-else-if="file.status === 'success'">✓ 成功</span>
+                  <span v-else-if="file.status === 'failed'">
+                    ✗ 失败
+                    <van-button
+                      v-if="file.retryCount < 1"
+                      size="mini"
+                      @click.stop="
+                        retrySingleImage(file, 'RETURNED_INNER_IMAGE')
+                      "
+                      style="margin-left: 5px; background: #fff; color: #ee0a24"
+                    >
+                      重试
+                    </van-button>
+                    <span v-else style="margin-left: 5px">(已达上限)</span>
+                  </span>
+                </div>
+
+                <van-button
+                  class="cover-button"
+                  size="mini"
+                  icon="edit"
+                  @click.stop="handleInnerImages(file, index)"
+                >
+                  编辑
+                </van-button>
+              </div>
+            </template>
+          </van-uploader>
         </div>
 
         <div class="button-group">
-          <van-space>
-            <van-button type="warning" plain @click="previewAll">预览全部</van-button>
-            <van-button type="danger" plain @click="onReset">重置图片</van-button>
-            <van-button type="primary" @click="onSubmit">提交</van-button>
-          </van-space>
+          <van-button class="action-btn" type="warning" plain @click="previewAll">预览全部</van-button>
+          <van-button class="action-btn action-btn--danger" type="danger" plain @click="onReset">重置图片</van-button>
+          <van-button
+            class="action-btn"
+            type="primary"
+            plain
+            @click="retryAllFailedImages"
+            :disabled="!hasFailedImages()"
+          >重传失败图片</van-button>
+          <van-button class="action-btn" type="primary" @click="onSubmit">提交</van-button>
         </div>
       </div>
     </div>
+
+    <!-- 自定义图片预览组件 -->
+    <van-image-preview
+      v-model:show="previewVisible"
+      :images="previewImages"
+      :start-position="previewStartPosition"
+      @change="handlePreviewChange"
+    >
+      <!-- 自定义操作按钮 -->
+      <template #index>
+        <div class="preview-toolbar">
+          <van-button
+            type="primary"
+            size="small"
+            @click="onEditImage(previewCurrentIndex)"
+            :disabled="
+              !canEditImage(getPreviewImage(previewCurrentIndex))
+            "
+          >
+            编辑
+          </van-button>
+        </div>
+      </template>
+    </van-image-preview>
+    <edit-image ref="editImageRef" @save-image="changeFile" />
   </div>
 </template>
 
-<script setup>
-import { ref,   onMounted } from 'vue'
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+// Type declarations for image upload handling
+enum UPLOAD_STATUS {
+  PENDING = 'pending',
+  UPLOADING = 'uploading',
+  SUCCESS = 'success',
+  FAILED = 'failed',
+  RETRYING = 'retrying',
+}
+
+interface UploadImage {
+  file: File
+  status: UPLOAD_STATUS
+  url: string | null
+  error: string | null
+  retryCount: number
+  originalFile: File
+  // Optional; some flows may carry raw content for previews
+  content?: string
+}
+
+interface Workbench {
+  warehouseCode: string
+  workStation: string
+}
+
+interface EditImageExposed {
+  editImage: (image: UploadImage, type: 'outer' | 'inner') => void
+}
 import {
   showFailToast,
   showNotify,
   showLoadingToast,
   closeToast,
   showConfirmDialog,
-  showImagePreview
 } from 'vant'
-import { getHeader, goBack, } from '@/utils/android'
-import {
-  detailImageUpload,
-  returnedWorkbench
-} from '@/api/returned/index.ts'
+import { getHeader, goBack } from '@/utils/android'
+import { detailImageUpload, returnedWorkbench } from '@/api/returned/index.ts'
+import { compressImage } from '@/utils/imageCompression'
+import EditImage from '@/components/EditImage.vue'
 
+const workbench = ref<Workbench>({ warehouseCode: '', workStation: '' })
 
-try {
-  getHeader()
-} catch (error) {
-  console.log(error)
+onMounted(async () => {
+  try {
+    await getHeader()
+  } catch (error: any) {
+    if (typeof showFailToast === 'function') {
+      showFailToast(error?.message || '获取头信息失败,请稍后重试')
+    }
+    console.error(error)
+  }
+  getWorkbench()
+})
+
+// 图片上传相关 - 增强数据结构
+const outerImages = ref<UploadImage[]>([]) // 外箱图 [{file, status, url, error, retryCount, originalFile}]
+const innerImages = ref<UploadImage[]>([]) // 内物图 [{file, status, url, error, retryCount, originalFile}]
+
+// 预览相关变量
+const previewVisible = ref<boolean>(false)
+const previewImages = ref<string[]>([])
+const previewStartPosition = ref<number>(0)
+const previewCurrentIndex = ref<number>(0)
+
+// 编辑权限检查
+const canEditImage = (imageObj: UploadImage | undefined): boolean => {
+  if (!imageObj) return false
+  // 只有上传中和重试中的图片不能编辑
+  return (
+    imageObj.status !== UPLOAD_STATUS.UPLOADING &&
+    imageObj.status !== UPLOAD_STATUS.RETRYING
+  )
 }
 
-const workbench = ref({})
+// 预览索引变更处理
+const handlePreviewChange = (index: number): void => {
+  previewCurrentIndex.value = index
+}
 
-// 图片上传相关
-const outerImages = ref([]) // 外箱图
-const innerImages = ref([]) // 内物图
 
-onMounted(()=>{
-  getWorkbench()
-})
 
-function getWorkbench(){
-  returnedWorkbench().then(res=>{
-    workbench.value =  res.data
-  })
+function getWorkbench(): void {
+  returnedWorkbench()
+    .then((res) => {
+      workbench.value = res.data
+    })
+    .catch((error: any) => {
+      // 记录错误并提示用户,避免静默失败
+      console.error('getWorkbench error', error)
+      showFailToast('获取工作台信息失败,请稍后重试')
+    })
 }
 
-const beforeReadImage = (file) => {
+const beforeReadImage = (file: File | File[]): any => {
   const files = Array.isArray(file) ? file : [file]
   for (const f of files) {
-    if (file.name.toLowerCase().endsWith('.heic') ||
-      file.name.toLowerCase().endsWith('.heif')) {
+    if (
+      f.name.toLowerCase().endsWith('.heic') ||
+      f.name.toLowerCase().endsWith('.heif')
+    ) {
       showFailToast('不支持的图片格式')
       return false
     }
@@ -117,84 +277,137 @@ const beforeReadImage = (file) => {
       return false
     }
     if (f.size > 15 * 1024 * 1024) {
-      showFailToast('图片大小不能超过10MB')
+      showFailToast('图片大小不能超过15MB')
       return false
     }
   }
-  return true
+
+  // 返回增强的图片对象数组,包含状态信息
+  return files.map((f) => ({
+    file: f, // 原始File对象
+    status: UPLOAD_STATUS.PENDING,
+    url: null,
+    error: null,
+    retryCount: 0,
+    originalFile: f, // 保留用于重传
+  }))
 }
 
+// 单张图片上传函数
+const uploadSingleImage = async (
+  imageObj: UploadImage,
+  category: string,
+): Promise<boolean> => {
+  imageObj.status = UPLOAD_STATUS.UPLOADING
 
-// 提交时上传:这里提供示例函数,后续可替换为真实上传API
-const simulateUpload = async (file,type) => {
-  // 模拟网络耗时
-  const compressedFile = await compressImage(file, 0.5)
-  const data = new FormData()
-  data.set('file',compressedFile)
-  data.set('type',type)
-  await detailImageUpload(data)
-  // 返回可预览URL(示例)
-  return { url: URL.createObjectURL(file) }
+  try {
+    const compressedFile = await compressImage(imageObj.file, 0.5)
+    const data = new FormData()
+    data.set('file', compressedFile)
+    data.set('type', category)
+    // detailImageUpload成功执行即成功,catch到异常即失败
+    await detailImageUpload(data)
+
+    imageObj.status = UPLOAD_STATUS.SUCCESS
+    imageObj.url = URL.createObjectURL(imageObj.file)
+    imageObj.error = null
+    return true
+  } catch (error) {
+    // 标记失败并记录错误,但不要将内部错误信息暴露给用户
+    imageObj.status = UPLOAD_STATUS.FAILED
+    imageObj.error = '上传失败,请稍后再试'
+    // retryCount 不在此处修改,由重试调用方负责递增
+    // 记录详细错误以供调试
+    console.error('uploadSingleImage error', error)
+    return false
+  }
 }
 
-const compressImage = (file, quality = 0.5) => {
-  return new Promise((resolve) => {
-    const canvas = document.createElement('canvas')
-    const ctx = canvas.getContext('2d')
-    const img = new Image()
-    img.onload = () => {
-      // 计算新尺寸(设置为原来的一半)
-
-      // 设置canvas尺寸
-      canvas.width = img.width
-      canvas.height = img.height
-
-      // 在canvas上绘制调整后的图片
-      ctx.drawImage(img, 0, 0, img.width, img.height)
-      // 将canvas转换为Blob对象
-      canvas.toBlob(resolve, 'image/jpeg', quality)
-    }
+const uploadCategoryWithStatus = async (
+  list: UploadImage[],
+  categoryName: string,
+): Promise<{ success: boolean; item: UploadImage }[]> => {
+  const results: { success: boolean; item: UploadImage }[] = []
 
-    // 读取上传的图片文件
-    const reader = new FileReader()
-    reader.onload = (e) => {
-      img.src = e.target.result
+  for (const item of list) {
+    // 跳过已成功的图片
+    if (item.status === UPLOAD_STATUS.SUCCESS) {
+      results.push({ success: true, item })
+      continue
     }
-    reader.readAsDataURL(file)
-  })
+    console.log(item,categoryName)
+    const success = await uploadSingleImage(item, categoryName)
+    results.push({ success, item })
+  }
+
+  return results
 }
 
-const uploadCategory = async (list, categoryName) => {
-  // 逐张上传,保持与“单张选择”一致
-  for (const item of list) {
-    const file = item.file || item
-    try {
-      const res = await simulateUpload(file,categoryName)
-      item.url = res.url
-    } catch (e) {
-      throw new Error(`${categoryName}上传失败`)
-    }
+// 获取预览图片对象
+const getPreviewImage = (index: number): UploadImage => {
+  const allImages = [...outerImages.value, ...innerImages.value]
+  return allImages[index] as UploadImage
+}
+
+const onEditImage = (index: number): void => {
+  // 获取图片对象
+  const imageObj = getPreviewImage(index)
+
+  if (!imageObj) return
+
+  // 检查是否可以编辑
+  if (!canEditImage(imageObj)) {
+    showNotify({
+      type: 'warning',
+      message: '图片正在上传中,请稍后再编辑',
+    })
+    return
+  }
+
+  // 关闭预览
+  previewVisible.value = false
+
+  // 确定图片类型和索引
+  let type: 'outer' | 'inner'
+  if (index < outerImages.value.length) {
+    // 是外箱图
+    type = 'outer'
+    outerImagesIndex.value = index
+  } else {
+    // 是内物图
+    type = 'inner'
+    innerImagesIndex.value = index - outerImages.value.length
   }
+
+  // 打开编辑界面
+  setTimeout(() => {
+    editImageRef.value?.editImage(imageObj, type)
+  }, 300)
 }
 
-const previewAll = () => {
+const previewAll = (): void => {
   const images = [...outerImages.value, ...innerImages.value]
-    .map((it) => (it.url || it.content))
+    .map(
+      (it) => it.url || (it.file ? URL.createObjectURL(it.file) : it.content),
+    )
     .filter(Boolean)
   if (!images.length) {
     showNotify({ type: 'warning', message: '暂无可预览的图片' })
     return
   }
-  showImagePreview(images)
+  previewImages.value = images
+  previewStartPosition.value = 0
+  previewCurrentIndex.value = 0
+  previewVisible.value = true
 }
 
-const onReset = () => {
+const onReset = (): void => {
   outerImages.value = []
   innerImages.value = []
   showNotify({ type: 'primary', message: '已重置图片' })
 }
 
-const onSubmit = async () => {
+const onSubmit = async (): Promise<void> => {
   if (!outerImages.value.length && !innerImages.value.length) {
     showFailToast('请先上传外箱图或内物图')
     return
@@ -204,7 +417,7 @@ const onSubmit = async () => {
     const innerCount = innerImages.value.length
     await showConfirmDialog({
       title: '确认提交',
-      message: `外箱图:${outerCount} 张\n内物图:${innerCount} 张\n是否确认提交?`
+      message: `外箱图:${outerCount} 张\n内物图:${innerCount} 张\n是否确认提交?`,
     })
   } catch (e) {
     showNotify({ type: 'warning', message: '已取消提交' })
@@ -213,21 +426,249 @@ const onSubmit = async () => {
 
   const toast = showLoadingToast({ duration: 0, message: '上传中...' })
   try {
-    // 分开上传:先外箱图,再内物图(或根据需要并行)
-    await uploadCategory(outerImages.value, 'RETURNED_BOX_IMAGE')
-    await uploadCategory(innerImages.value, 'RETURNED_INNER_IMAGE')
+    // 使用新的上传函数
+    const outerResults = await uploadCategoryWithStatus(
+      outerImages.value,
+      'RETURNED_BOX_IMAGE',
+    )
+    const innerResults = await uploadCategoryWithStatus(
+      innerImages.value,
+      'RETURNED_INNER_IMAGE',
+    )
 
     closeToast()
-    showNotify({ type: 'success', message: '提交成功' })
 
-    // 清空列表
-    outerImages.value = []
-    innerImages.value = []
-  } catch (e) {
+    // 统计结果
+    const allResults = [...outerResults, ...innerResults]
+    const successCount = allResults.filter((r) => r.success).length
+    const failedCount = allResults.filter((r) => !r.success).length
+
+    if (failedCount === 0) {
+      // 全部成功:清空所有图片
+      showNotify({ type: 'success', message: '提交成功' })
+      outerImages.value = []
+      innerImages.value = []
+    } else {
+      // 有失败图片:只清空成功的,保留失败的
+      showNotify({
+        type: 'warning',
+        duration: 5000,
+        message: `上传完成,成功 ${successCount} 张,失败 ${failedCount} 张。失败图片已保留,可手动重传。`,
+      })
+
+      // 只清空成功的图片
+      outerImages.value = outerImages.value.filter(
+        (img) => img.status !== UPLOAD_STATUS.SUCCESS,
+      )
+      innerImages.value = innerImages.value.filter(
+        (img) => img.status !== UPLOAD_STATUS.SUCCESS,
+      )
+    }
+  } catch (error) {
+    // 保证用户看到的错误信息不包含内部实现细节
     closeToast()
-    showFailToast(e?.message || '提交失败,请重试')
+    console.error('onSubmit error', error)
+    showFailToast('提交失败,请稍后再试')
+  }
+}
+
+// 手动重传相关函数
+// 单张图片重传
+const retrySingleImage = async (
+  imageObj: UploadImage,
+  category: string,
+): Promise<void> => {
+  // 检查重传次数
+  if (imageObj.retryCount >= 1) {
+    showNotify({ type: 'warning', message: '已达到最大重传次数' })
+    return
+  }
+
+  imageObj.status = UPLOAD_STATUS.RETRYING
+  const success = await uploadSingleImage(imageObj, category)
+  imageObj.retryCount++ // 无论成功失败,重试次数+1
+
+  if (success) {
+    showNotify({ type: 'success', message: '重传成功' })
+    // 成功后会从列表中移除(在下次提交时)
+  } else {
+    showNotify({
+      type: 'danger',
+      message: `重传失败`,
+    })
   }
 }
+
+// 批量重传失败图片
+const retryAllFailedImages = async (): Promise<void> => {
+  const allImages = [...outerImages.value, ...innerImages.value]
+  const failedImages = allImages.filter(
+    (img) => img.status === UPLOAD_STATUS.FAILED && img.retryCount < 1,
+  )
+
+  if (failedImages.length === 0) {
+    showNotify({ type: 'primary', message: '没有需要重传的图片' })
+    return
+  }
+
+  const toast = 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'
+    const success = await uploadSingleImage(img, category)
+    img.retryCount++ // 无论成功失败,重试次数+1
+    if (success) successCount++
+  }
+
+  closeToast()
+  showNotify({
+    type: successCount === failedImages.length ? 'success' : 'warning',
+    message: `重传完成,成功 ${successCount}/${failedImages.length} 张`,
+  })
+}
+
+// 计算是否有失败图片
+const hasFailedImages = (): boolean => {
+  const allImages = [
+    ...outerImages.value,
+    ...innerImages.value,
+  ] as UploadImage[]
+  return allImages.some(
+    (img: UploadImage) =>
+      img.status === UPLOAD_STATUS.FAILED && img.retryCount < 1,
+  )
+}
+
+// 外箱图
+const outerImagesIndex = ref<number>(-1)
+// 内物图
+const innerImagesIndex = ref<number>(-1)
+const editImageRef = ref<EditImageExposed | null>(null)
+
+/**
+ * 处理图片编辑操作
+ * @param file 文件对象
+ * @param index 文件下标
+ */
+const handleInnerImages = (_slotFile: unknown, index: number): void => {
+  // 直接从响应式列表取原始对象,避免 Vant 插槽包装层导致 file 字段异常
+  const imageObj = innerImages.value[index]
+  if (!imageObj) return
+
+  if (!canEditImage(imageObj)) {
+    showNotify({
+      type: 'warning',
+      message: '图片正在上传中,请稍后再编辑',
+    })
+    return
+  }
+
+  // 保证传入 EditImage 时有可用的预览 URL
+  if (!imageObj.url && !imageObj.content && imageObj.file instanceof File) {
+    imageObj.content = URL.createObjectURL(imageObj.file)
+  }
+
+  innerImagesIndex.value = index
+  editImageRef.value?.editImage(imageObj, 'inner')
+}
+
+/**
+ * 处理图片编辑操作
+ * @param _slotFile 插槽传来的对象(Vant 包装层,不可靠,弃用)
+ * @param index 文件下标
+ */
+const handleOuterImages = (_slotFile: unknown, index: number): void => {
+  // 直接从响应式列表取原始对象,避免 Vant 插槽包装层导致 file 字段异常
+  const imageObj = outerImages.value[index]
+  if (!imageObj) return
+
+  if (!canEditImage(imageObj)) {
+    showNotify({
+      type: 'warning',
+      message: '图片正在上传中,请稍后再编辑',
+    })
+    return
+  }
+
+  // 保证传入 EditImage 时有可用的预览 URL
+  if (!imageObj.url && !imageObj.content && imageObj.file instanceof File) {
+    imageObj.content = URL.createObjectURL(imageObj.file)
+  }
+
+  outerImagesIndex.value = index
+  editImageRef.value?.editImage(imageObj, 'outer')
+}
+
+const changeFile = async (
+  editedDataURL: string,
+  type: 'inner' | 'outer',
+): Promise<void> => {
+  try {
+    // 根据type找到对应的图片索引和列表
+    const index =
+      type === 'inner' ? innerImagesIndex.value : outerImagesIndex.value
+    const imageList = type === 'inner' ? innerImages.value : outerImages.value
+
+    if (index === -1 || !imageList[index]) {
+      showNotify({ type: 'warning', message: '未找到对应的图片' })
+      return
+    }
+
+    const imageObj = imageList[index]
+
+    // 将base64转换为File对象
+    const editedFile = dataURLtoFile(editedDataURL, imageObj.file.name)
+
+    // 压缩图片(EditImage保存的是PNG,可能很大)
+    const compressedFile = await compressImage(editedFile)
+
+    // 释放旧的预览URL(内存管理)
+    if (imageObj.url && imageObj.url.startsWith('blob:')) {
+      URL.revokeObjectURL(imageObj.url)
+    }
+
+    // 只更新文件内容和预览URL
+    imageObj.file = compressedFile
+    imageObj.originalFile = compressedFile
+    imageObj.url = editedDataURL // 使用原始的base64作为预览(质量更好)
+
+    // 若图片已成功上传,编辑后需重置为 PENDING,确保提交时重新上传编辑后的版本
+    if (imageObj.status === UPLOAD_STATUS.SUCCESS) {
+      imageObj.status = UPLOAD_STATUS.PENDING
+      imageObj.error = null
+    }
+    // 其余状态(PENDING / FAILED)保持不变,由正常上传流程处理
+
+    showNotify({ type: 'success', message: '图片编辑完成' })
+  } catch (error) {
+    console.error('图片编辑保存失败:', error)
+    showNotify({
+      type: 'danger',
+      message: '图片编辑保存失败,请重试',
+    })
+  }
+}
+
+// base64转File函数
+function dataURLtoFile(dataurl: string, filename: string): File {
+  const arr = dataurl.split(',')
+  const mimeMatch = arr[0].match(/:(.*?);/)
+  if (!mimeMatch) throw new Error('无效的 dataURL 格式')
+  const mime = mimeMatch[1]
+  const bstr = atob(arr[1])
+  let n = bstr.length
+  const u8arr = new Uint8Array(n)
+  while (n--) {
+    u8arr[n] = bstr.charCodeAt(n)
+  }
+  return new File([u8arr], filename, { type: mime })
+}
 </script>
 
 <style scoped lang="sass">
@@ -241,6 +682,29 @@ const onSubmit = async () => {
   .right-btn
     color: #fff
 
+// ── 仓库信息区 ──
+.workbench-info
+  background: #fff
+  padding: 10px 14px 8px
+  border-bottom: 1px solid #f0f0f0
+
+  .info-line
+    margin: 0 0 4px
+    font-size: 14px
+    color: #666
+
+  .info-value
+    color: #1989fa
+    font-weight: 500
+
+  .hint-text
+    color: #999
+    font-size: 12px
+
+  .refresh-btn
+    margin-top: 4px
+
+// ── 主内容区 ──
 .container
   .init-container
     width: 100%
@@ -252,7 +716,17 @@ const onSubmit = async () => {
         padding: 5px
 
       .button-group
-        padding: 5px
+        display: flex
+        gap: 8px
+        padding: 8px 5px
+        flex-wrap: wrap
+
+        .action-btn
+          flex: 1
+          min-width: 70px
+          height: 38px
+          font-size: 13px
+          border-radius: 8px
 
   .content
     width: 100%
@@ -278,4 +752,61 @@ const onSubmit = async () => {
           margin-bottom: 12px
           font-size: 12px
 
+// 图片预览工具栏样式
+.preview-toolbar
+  display: flex
+  justify-content: flex-end
+  margin-top: 10px
+  padding: 0 15px
+
+  .van-button
+    margin-left: 10px
+
+.custom-cover
+  position: absolute
+  top: 0
+  left: 0
+  box-sizing: border-box
+  width: 100%
+  height: 100%
+  display: flex
+  align-items: flex-end
+  /* 按钮通常放在底部 */
+  justify-content: center
+  padding: 8px
+  /* 留出边距 */
+  background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent)
+  /* 可选:底部渐变背景,让按钮更清晰 */
+  pointer-events: none
+/* 关键:避免覆盖层拦截图片的点击预览事件 */
+
+.cover-button
+  pointer-events: auto
+/* 关键:允许按钮本身接收点击事件 */
+
+// 上传状态样式
+.upload-status
+  position: absolute
+  top: 5px
+  left: 5px
+  padding: 2px 6px
+  border-radius: 3px
+  font-size: 12px
+  color: white
+  background-color: rgba(0, 0, 0, 0.7)
+
+.upload-status.success
+  background-color: #07c160
+
+.upload-status.failed
+  background-color: #ee0a24
+
+.upload-status.uploading
+  background-color: #1989fa
+
+.upload-status.retrying
+  background-color: #ff976a
+
+.upload-status.pending
+  background-color: #969799
 </style>