|
@@ -40,7 +40,16 @@
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
-import { computed, type CSSProperties, getCurrentInstance, onMounted, ref } from 'vue'
|
|
|
|
|
|
|
+import {
|
|
|
|
|
+ computed,
|
|
|
|
|
+ type CSSProperties,
|
|
|
|
|
+ getCurrentInstance,
|
|
|
|
|
+ nextTick,
|
|
|
|
|
+ onBeforeUnmount,
|
|
|
|
|
+ onMounted,
|
|
|
|
|
+ ref,
|
|
|
|
|
+ watch,
|
|
|
|
|
+} from 'vue'
|
|
|
|
|
|
|
|
import { getLocation } from '@/lib/location'
|
|
import { getLocation } from '@/lib/location'
|
|
|
|
|
|
|
@@ -52,8 +61,8 @@ type FieldValue = string
|
|
|
type UploadSource = 'camera' | 'album'
|
|
type UploadSource = 'camera' | 'album'
|
|
|
|
|
|
|
|
interface UploadResponse {
|
|
interface UploadResponse {
|
|
|
- code: number
|
|
|
|
|
- success: boolean
|
|
|
|
|
|
|
+ code: number | string
|
|
|
|
|
+ success?: boolean
|
|
|
msg: string | null
|
|
msg: string | null
|
|
|
data: {
|
|
data: {
|
|
|
url: string
|
|
url: string
|
|
@@ -79,6 +88,10 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
canvasId: 'imgUploadWatermarkCanvas',
|
|
canvasId: 'imgUploadWatermarkCanvas',
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+const emit = defineEmits<{
|
|
|
|
|
+ (event: 'uploading-change', uploading: boolean): void
|
|
|
|
|
+}>()
|
|
|
|
|
+
|
|
|
const value = defineModel<FieldValue>('value', {
|
|
const value = defineModel<FieldValue>('value', {
|
|
|
default: '',
|
|
default: '',
|
|
|
})
|
|
})
|
|
@@ -97,6 +110,11 @@ const watermarkCanvasHeight = ref(1)
|
|
|
let watermarkAddressPromise: Promise<void> | null = null
|
|
let watermarkAddressPromise: Promise<void> | null = null
|
|
|
|
|
|
|
|
const baseUrl = String(import.meta.env.VITE_BASE_API ?? '').replace(/\/$/, '')
|
|
const baseUrl = String(import.meta.env.VITE_BASE_API ?? '').replace(/\/$/, '')
|
|
|
|
|
+const MAX_WATERMARK_LONG_EDGE = 1920
|
|
|
|
|
+const WATERMARK_JPEG_QUALITY = 0.82
|
|
|
|
|
+const WATERMARK_TIMEOUT_MS = 20000
|
|
|
|
|
+const UPLOAD_TIMEOUT_MS = 30000
|
|
|
|
|
+const uploadLogEnabled = import.meta.env.DEV || import.meta.env.VITE_REQUEST_LOG === 'true'
|
|
|
|
|
|
|
|
const isDisabled = computed(() => props.disabled)
|
|
const isDisabled = computed(() => props.disabled)
|
|
|
|
|
|
|
@@ -207,16 +225,25 @@ const chooseImage = async () => {
|
|
|
|
|
|
|
|
const uploadedUrls: string[] = []
|
|
const uploadedUrls: string[] = []
|
|
|
let failedCount = 0
|
|
let failedCount = 0
|
|
|
|
|
+ let firstFailureMessage = ''
|
|
|
|
|
|
|
|
for (const filePath of filePaths) {
|
|
for (const filePath of filePaths) {
|
|
|
try {
|
|
try {
|
|
|
- const uploadFilePath = source === 'camera' ? await addWatermark(filePath) : filePath
|
|
|
|
|
|
|
+ const uploadFilePath =
|
|
|
|
|
+ source === 'camera'
|
|
|
|
|
+ ? await withTimeout(addWatermark(filePath), WATERMARK_TIMEOUT_MS, '图片处理超时')
|
|
|
|
|
+ : filePath
|
|
|
const uploadedUrl = await upload(uploadFilePath)
|
|
const uploadedUrl = await upload(uploadFilePath)
|
|
|
|
|
|
|
|
uploadedUrls.push(appendUploadSourceSuffix(uploadedUrl, source))
|
|
uploadedUrls.push(appendUploadSourceSuffix(uploadedUrl, source))
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
failedCount += 1
|
|
failedCount += 1
|
|
|
- console.error('[ImgUpload] 单张图片上传失败', error)
|
|
|
|
|
|
|
+ firstFailureMessage ||= getErrorMessage(error)
|
|
|
|
|
+ console.error('[ImgUpload] 图片处理或上传失败', {
|
|
|
|
|
+ source,
|
|
|
|
|
+ message: getErrorMessage(error),
|
|
|
|
|
+ error,
|
|
|
|
|
+ })
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -230,7 +257,7 @@ const chooseImage = async () => {
|
|
|
if (!allUploaded) {
|
|
if (!allUploaded) {
|
|
|
resultTitle = uploadedUrls.length
|
|
resultTitle = uploadedUrls.length
|
|
|
? `${uploadedUrls.length}张成功,${failedCount}张失败`
|
|
? `${uploadedUrls.length}张成功,${failedCount}张失败`
|
|
|
- : '上传失败'
|
|
|
|
|
|
|
+ : firstFailureMessage || '上传失败'
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
hideUploadLoading()
|
|
hideUploadLoading()
|
|
@@ -297,10 +324,18 @@ const chooseImages = (count: number, source: UploadSource): Promise<string[]> =>
|
|
|
|
|
|
|
|
const upload = (filePath: string): Promise<string> => {
|
|
const upload = (filePath: string): Promise<string> => {
|
|
|
return new Promise((resolve, reject) => {
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
+ const startedAt = Date.now()
|
|
|
|
|
+ const uploadUrl = `${baseUrl}/admin/api/file/upload/mobile`
|
|
|
|
|
+
|
|
|
|
|
+ if (uploadLogEnabled) {
|
|
|
|
|
+ console.info(`[ImgUpload][upload] POST ${uploadUrl}`)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
uni.uploadFile({
|
|
uni.uploadFile({
|
|
|
- url: `${baseUrl}/admin/api/file/upload/mobile`,
|
|
|
|
|
|
|
+ url: uploadUrl,
|
|
|
filePath,
|
|
filePath,
|
|
|
name: 'file',
|
|
name: 'file',
|
|
|
|
|
+ timeout: UPLOAD_TIMEOUT_MS,
|
|
|
header: {
|
|
header: {
|
|
|
Authorization: `Bearer ${userStore.access_token}`,
|
|
Authorization: `Bearer ${userStore.access_token}`,
|
|
|
},
|
|
},
|
|
@@ -311,20 +346,46 @@ const upload = (filePath: string): Promise<string> => {
|
|
|
if (
|
|
if (
|
|
|
res.statusCode < 200 ||
|
|
res.statusCode < 200 ||
|
|
|
res.statusCode >= 300 ||
|
|
res.statusCode >= 300 ||
|
|
|
- !parsed.success ||
|
|
|
|
|
- parsed.code !== 0 ||
|
|
|
|
|
|
|
+ String(parsed.code) !== '0' ||
|
|
|
!parsed.data?.url
|
|
!parsed.data?.url
|
|
|
) {
|
|
) {
|
|
|
- reject(new Error(parsed.msg || 'upload failed'))
|
|
|
|
|
|
|
+ const error = new Error(parsed.msg || `上传失败(${res.statusCode})`)
|
|
|
|
|
+
|
|
|
|
|
+ console.error('[ImgUpload][upload] 接口返回失败', {
|
|
|
|
|
+ statusCode: res.statusCode,
|
|
|
|
|
+ code: parsed.code,
|
|
|
|
|
+ message: parsed.msg,
|
|
|
|
|
+ durationMs: Date.now() - startedAt,
|
|
|
|
|
+ })
|
|
|
|
|
+ reject(error)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ if (uploadLogEnabled) {
|
|
|
|
|
+ console.info('[ImgUpload][upload] 上传成功', {
|
|
|
|
|
+ statusCode: res.statusCode,
|
|
|
|
|
+ durationMs: Date.now() - startedAt,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
resolve(parsed.data.url)
|
|
resolve(parsed.data.url)
|
|
|
- } catch {
|
|
|
|
|
- reject(new Error('parse upload response failed'))
|
|
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('[ImgUpload][upload] 响应解析失败', {
|
|
|
|
|
+ statusCode: res.statusCode,
|
|
|
|
|
+ response: res.data,
|
|
|
|
|
+ durationMs: Date.now() - startedAt,
|
|
|
|
|
+ error,
|
|
|
|
|
+ })
|
|
|
|
|
+ reject(new Error('上传结果解析失败'))
|
|
|
}
|
|
}
|
|
|
},
|
|
},
|
|
|
- fail: reject,
|
|
|
|
|
|
|
+ fail: (error) => {
|
|
|
|
|
+ console.error('[ImgUpload][upload] 网络请求失败', {
|
|
|
|
|
+ message: error.errMsg,
|
|
|
|
|
+ durationMs: Date.now() - startedAt,
|
|
|
|
|
+ error,
|
|
|
|
|
+ })
|
|
|
|
|
+ reject(new Error(error.errMsg || '上传失败'))
|
|
|
|
|
+ },
|
|
|
})
|
|
})
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
@@ -333,12 +394,25 @@ const addWatermark = (filePath: string): Promise<string> => {
|
|
|
return new Promise((resolve, reject) => {
|
|
return new Promise((resolve, reject) => {
|
|
|
uni.getImageInfo({
|
|
uni.getImageInfo({
|
|
|
src: filePath,
|
|
src: filePath,
|
|
|
- success: (imageInfo) => {
|
|
|
|
|
- const width = imageInfo.width
|
|
|
|
|
- const height = imageInfo.height
|
|
|
|
|
|
|
+ success: async (imageInfo) => {
|
|
|
|
|
+ const sourceWidth = Number(imageInfo.width)
|
|
|
|
|
+ const sourceHeight = Number(imageInfo.height)
|
|
|
|
|
+
|
|
|
|
|
+ if (!sourceWidth || !sourceHeight) {
|
|
|
|
|
+ reject(new Error('无法读取图片尺寸'))
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const canvasScale = Math.min(
|
|
|
|
|
+ 1,
|
|
|
|
|
+ MAX_WATERMARK_LONG_EDGE / Math.max(sourceWidth, sourceHeight)
|
|
|
|
|
+ )
|
|
|
|
|
+ const width = Math.max(1, Math.round(sourceWidth * canvasScale))
|
|
|
|
|
+ const height = Math.max(1, Math.round(sourceHeight * canvasScale))
|
|
|
|
|
|
|
|
watermarkCanvasWidth.value = width
|
|
watermarkCanvasWidth.value = width
|
|
|
watermarkCanvasHeight.value = height
|
|
watermarkCanvasHeight.value = height
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
|
|
|
const componentInstance = instance?.proxy
|
|
const componentInstance = instance?.proxy
|
|
|
const ctx = uni.createCanvasContext(
|
|
const ctx = uni.createCanvasContext(
|
|
@@ -361,7 +435,7 @@ const addWatermark = (filePath: string): Promise<string> => {
|
|
|
const textTop = 6 * scaleRatio
|
|
const textTop = 6 * scaleRatio
|
|
|
const fontSize = 10 * scaleRatio
|
|
const fontSize = 10 * scaleRatio
|
|
|
|
|
|
|
|
- ctx.drawImage(filePath, 0, 0, width, height)
|
|
|
|
|
|
|
+ ctx.drawImage(imageInfo.path || filePath, 0, 0, width, height)
|
|
|
ctx.setFontSize(fontSize)
|
|
ctx.setFontSize(fontSize)
|
|
|
|
|
|
|
|
const text = getWatermarkText()
|
|
const text = getWatermarkText()
|
|
@@ -402,6 +476,11 @@ const addWatermark = (filePath: string): Promise<string> => {
|
|
|
{
|
|
{
|
|
|
canvasId: props.canvasId,
|
|
canvasId: props.canvasId,
|
|
|
fileType: 'jpg',
|
|
fileType: 'jpg',
|
|
|
|
|
+ width,
|
|
|
|
|
+ height,
|
|
|
|
|
+ destWidth: width,
|
|
|
|
|
+ destHeight: height,
|
|
|
|
|
+ quality: WATERMARK_JPEG_QUALITY,
|
|
|
success: (res) => {
|
|
success: (res) => {
|
|
|
resolve(res.tempFilePath)
|
|
resolve(res.tempFilePath)
|
|
|
},
|
|
},
|
|
@@ -584,6 +663,46 @@ const isCancelError = (error: unknown) => {
|
|
|
|
|
|
|
|
return errMsg.includes('cancel')
|
|
return errMsg.includes('cancel')
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+const getErrorMessage = (error: unknown): string => {
|
|
|
|
|
+ if (error instanceof Error) return error.message
|
|
|
|
|
+
|
|
|
|
|
+ if (error && typeof error === 'object') {
|
|
|
|
|
+ const rawError = error as { errMsg?: unknown; msg?: unknown }
|
|
|
|
|
+ const message = rawError.errMsg ?? rawError.msg
|
|
|
|
|
+
|
|
|
|
|
+ if (typeof message === 'string') return message
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return ''
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const withTimeout = <T,>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> => {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ const timer = setTimeout(() => reject(new Error(message)), timeoutMs)
|
|
|
|
|
+
|
|
|
|
|
+ promise.then(
|
|
|
|
|
+ (result) => {
|
|
|
|
|
+ clearTimeout(timer)
|
|
|
|
|
+ resolve(result)
|
|
|
|
|
+ },
|
|
|
|
|
+ (error) => {
|
|
|
|
|
+ clearTimeout(timer)
|
|
|
|
|
+ reject(error)
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+watch(uploading, (isUploading) => {
|
|
|
|
|
+ emit('uploading-change', isUploading)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+onBeforeUnmount(() => {
|
|
|
|
|
+ if (uploading.value) {
|
|
|
|
|
+ emit('uploading-change', false)
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
<style lang="scss" scoped>
|