Bladeren bron

拍照优化

zengjun 1 maand geleden
bovenliggende
commit
cd7f1b4cb0
4 gewijzigde bestanden met toevoegingen van 737 en 106 verwijderingen
  1. 2 0
      package.json
  2. 98 0
      src/constants/editImageConfig.ts
  3. 1 1
      src/static/setting.txt
  4. 636 105
      src/views/returned/register/index.vue

+ 2 - 0
package.json

@@ -13,7 +13,9 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@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",

+ 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

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