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