فهرست منبع

完成canvas签名灰色内容

yuanmingze 1 هفته پیش
والد
کامیت
1442e0404c

+ 120 - 48
src/pages-common/signature/components/SignaturePad.vue

@@ -3,8 +3,12 @@
     <!-- 左侧操作栏 -->
     <view class="sidebar">
       <view class="btns">
-        <view class="button" @click="handleClear">重签</view>
-        <view class="button active" @click="handleSubmit">完成签名</view>
+        <view class="button" :class="{ 'is-disabled': generating }" @click="handleClear">
+          重签
+        </view>
+        <view class="button active" :class="{ 'is-disabled': generating }" @click="handleSubmit">
+          {{ generating ? '生成中' : '完成签名' }}
+        </view>
       </view>
     </view>
 
@@ -53,6 +57,8 @@ const rotateCanvasHeight = ref(1)
 
 const drawing = ref(false)
 const lastPoint = ref<{ x: number; y: number } | null>(null)
+const hasSignature = ref(false)
+const generating = ref(false)
 
 const rotateCanvasStyle = computed<CSSProperties>(() => ({
   width: `${rotateCanvasWidth.value}px`,
@@ -91,14 +97,14 @@ const drawBackground = () => {
 
 const onTouchStart = (e: any) => {
   const touch = (e as any).touches?.[0]
-  if (!touch || !ctx.value) return
+  if (!touch || !ctx.value || generating.value) return
 
   drawing.value = true
   lastPoint.value = { x: touch.x, y: touch.y }
 }
 
 const onTouchMove = (e: any) => {
-  if (!drawing.value || !ctx.value || !lastPoint.value) return
+  if (!drawing.value || !ctx.value || !lastPoint.value || generating.value) return
 
   const touch = (e as any).touches?.[0]
   if (!touch) return
@@ -114,6 +120,7 @@ const onTouchMove = (e: any) => {
   ctx.value.stroke()
   ctx.value.draw(true)
 
+  hasSignature.value = true
   lastPoint.value = current
 }
 
@@ -123,39 +130,58 @@ const onTouchEnd = () => {
 }
 
 const handleClear = () => {
-  if (!ctx.value) return
+  if (!ctx.value || generating.value) return
 
   ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value)
+  hasSignature.value = false
   drawBackground()
   emit('clear')
 }
 
+const finishGeneration = () => {
+  generating.value = false
+  uni.hideLoading()
+}
+
 const handleSubmit = () => {
+  if (generating.value) return
+
   if (!ctx.value) {
     emit('error', new Error('签名画布未就绪'))
     return
   }
 
-  uni.showLoading({ title: '生成签名中' })
+  if (!hasSignature.value) {
+    emit('error', new Error('请先完成签名'))
+    return
+  }
+
+  generating.value = true
+  uni.showLoading({ title: '生成签名中', mask: true })
 
   // 等待最后一笔真正写入视图层后再导出,避免快速提交时拿到空画布。
-  ctx.value.draw(true, () => {
-    uni.canvasToTempFilePath(
-      {
-        canvasId: CANVAS_ID,
-        fileType: 'png',
-        quality: 1,
-        success(res) {
-          rotateImage(res.tempFilePath)
-        },
-        fail(err) {
-          uni.hideLoading()
-          emit('error', err)
+  try {
+    ctx.value.draw(true, () => {
+      uni.canvasToTempFilePath(
+        {
+          canvasId: CANVAS_ID,
+          fileType: 'png',
+          quality: 1,
+          success(res) {
+            rotateImage(res.tempFilePath)
+          },
+          fail(err) {
+            finishGeneration()
+            emit('error', err)
+          },
         },
-      },
-      instance?.proxy as any
-    )
-  })
+        instance?.proxy as any
+      )
+    })
+  } catch (error) {
+    finishGeneration()
+    emit('error', error)
+  }
 }
 
 const waitForRotateCanvasReady = (width: number, height: number): Promise<void> => {
@@ -184,6 +210,39 @@ const waitForRotateCanvasReady = (width: number, height: number): Promise<void>
   })
 }
 
+const validateRotatedSignature = (width: number, height: number): Promise<void> => {
+  return new Promise((resolve, reject) => {
+    uni.canvasGetImageData(
+      {
+        canvasId: ROTATE_CANVAS_ID,
+        x: 0,
+        y: 0,
+        width,
+        height,
+        success: (result) => {
+          const pixels = result.data ?? []
+
+          for (let index = 0; index < pixels.length; index += 4) {
+            const red = Number(pixels[index] ?? 255)
+            const green = Number(pixels[index + 1] ?? 255)
+            const blue = Number(pixels[index + 2] ?? 255)
+            const alpha = Number(pixels[index + 3] ?? 0)
+
+            if (alpha > 0 && red < 160 && green < 160 && blue < 160) {
+              resolve()
+              return
+            }
+          }
+
+          reject(new Error('签名生成异常,请重新签名'))
+        },
+        fail: reject,
+      },
+      instance?.proxy as any
+    )
+  })
+}
+
 const rotateImage = (src: string) => {
   uni.getImageInfo({
     src,
@@ -211,41 +270,50 @@ const rotateImage = (src: string) => {
           outputWidth
         )
 
-        rotateCtx.draw(false, () => {
-          uni.canvasToTempFilePath(
-            {
-              canvasId: ROTATE_CANVAS_ID,
-              width: outputWidth,
-              height: outputHeight,
-              destWidth: outputWidth,
-              destHeight: outputHeight,
-              fileType: 'png',
-              quality: 1,
-              success(res) {
-                rotateCanvasWidth.value = 1
-                rotateCanvasHeight.value = 1
-                uni.hideLoading()
-                emit('submit', res.tempFilePath)
+        rotateCtx.draw(false, async () => {
+          try {
+            await validateRotatedSignature(outputWidth, outputHeight)
+
+            uni.canvasToTempFilePath(
+              {
+                canvasId: ROTATE_CANVAS_ID,
+                width: outputWidth,
+                height: outputHeight,
+                destWidth: outputWidth,
+                destHeight: outputHeight,
+                fileType: 'png',
+                quality: 1,
+                success(res) {
+                  rotateCanvasWidth.value = 1
+                  rotateCanvasHeight.value = 1
+                  finishGeneration()
+                  emit('submit', res.tempFilePath)
+                },
+                fail(err) {
+                  rotateCanvasWidth.value = 1
+                  rotateCanvasHeight.value = 1
+                  finishGeneration()
+                  emit('error', err)
+                },
               },
-              fail(err) {
-                rotateCanvasWidth.value = 1
-                rotateCanvasHeight.value = 1
-                uni.hideLoading()
-                emit('error', err)
-              },
-            },
-            instance?.proxy as any
-          )
+              instance?.proxy as any
+            )
+          } catch (error) {
+            rotateCanvasWidth.value = 1
+            rotateCanvasHeight.value = 1
+            finishGeneration()
+            emit('error', error)
+          }
         })
       } catch (err) {
         rotateCanvasWidth.value = 1
         rotateCanvasHeight.value = 1
-        uni.hideLoading()
+        finishGeneration()
         emit('error', err)
       }
     },
     fail(err) {
-      uni.hideLoading()
+      finishGeneration()
       emit('error', err)
     },
   })
@@ -283,6 +351,10 @@ const rotateImage = (src: string) => {
         font-size: 28rpx;
       }
 
+      .button.is-disabled {
+        opacity: 0.55;
+      }
+
       .button.active {
         background-color: #4b9ef2;
         color: #ffffff;

+ 7 - 1
src/pages-common/signature/index.vue

@@ -46,6 +46,7 @@ const userStore = useUserStore()
 const pageInstance = getCurrentInstance()
 
 const type = ref<SignatureType | ''>('')
+const saving = ref(false)
 
 const accessToken = computed(() => userStore.access_token || '')
 
@@ -93,6 +94,7 @@ const uploadSignatureFile = (filePath: string): Promise<string> => {
       url: fileApi.fileUpload(),
       filePath,
       name: 'file',
+      timeout: 30000,
       header: {
         Authorization: `Bearer ${accessToken.value}`,
       },
@@ -131,6 +133,8 @@ const uploadSignatureFile = (filePath: string): Promise<string> => {
 }
 
 const handleSignatureDone = async (filePath: string) => {
+  if (saving.value) return
+
   if (!isSupportedSignatureType()) {
     showToast('签署类型异常')
     return
@@ -141,6 +145,7 @@ const handleSignatureDone = async (filePath: string) => {
     return
   }
 
+  saving.value = true
   uni.showLoading({
     title: '保存中',
     mask: true,
@@ -155,6 +160,7 @@ const handleSignatureDone = async (filePath: string) => {
     if ((error as { handled?: boolean })?.handled) return
     showToast(error instanceof Error ? error.message : '上传失败')
   } finally {
+    saving.value = false
     uni.hideLoading()
   }
 }
@@ -235,7 +241,7 @@ const handleClear = () => {
 const handleError = (error: unknown) => {
   console.error('[Signature] pad error:', error)
 
-  showToast('签名出错,请重试')
+  showToast(error instanceof Error ? error.message : '签名出错,请重试')
 }
 
 onLoad((query) => {

+ 7 - 2
src/pages-mine/personal-card/components/ProfessionalSkillCard.vue

@@ -99,11 +99,14 @@ function toFullUrl(url: string) {
 async function chooseCertificate() {
   if (uploading.value) return
 
+  uploading.value = true
+  let loadingVisible = false
+
   try {
     const filePath = await chooseImage()
 
-    uploading.value = true
     uni.showLoading({ title: '上传中', mask: true })
+    loadingVisible = true
 
     const url = await upload(filePath)
     certificateList.value = [...certificateList.value, url]
@@ -116,7 +119,9 @@ async function chooseCertificate() {
     uni.showToast({ title: '上传失败', icon: 'none' })
   } finally {
     uploading.value = false
-    uni.hideLoading()
+    if (loadingVisible) {
+      uni.hideLoading()
+    }
   }
 }
 

+ 68 - 1
src/pages-task/task-form/composables/taskWatermark.ts

@@ -14,6 +14,11 @@ interface WatermarkOptions {
 
 const MAX_WATERMARK_LONG_EDGE = 1920
 const WATERMARK_JPEG_QUALITY = 0.82
+const CANVAS_SAMPLE_RATIOS = [
+  [0.2, 0.2],
+  [0.5, 0.5],
+  [0.8, 0.2],
+] as const
 
 // 每个原生回调单独收口,超时后不会再进入下一步。
 const runCanvasStep = <T>(
@@ -46,6 +51,63 @@ const runCanvasStep = <T>(
   })
 }
 
+// 相机图片应当铺满画布。若采样点仍然透明,说明原图没有成功绘制,禁止继续上传空白图。
+const assertCanvasPainted = async (
+  canvasId: string,
+  width: number,
+  height: number,
+  componentInstance: ComponentPublicInstance | null | undefined
+): Promise<void> => {
+  for (const [xRatio, yRatio] of CANVAS_SAMPLE_RATIOS) {
+    const result = await runCanvasStep<UniApp.CanvasGetImageDataRes>(
+      '水印画布校验',
+      (resolve, reject) => {
+        uni.canvasGetImageData(
+          {
+            canvasId,
+            x: Math.min(Math.max(Math.floor(width * xRatio), 0), width - 1),
+            y: Math.min(Math.max(Math.floor(height * yRatio), 0), height - 1),
+            width: 1,
+            height: 1,
+            success: resolve,
+            fail: reject,
+          },
+          componentInstance
+        )
+      }
+    )
+    const alpha = Number(result.data?.[3] ?? 0)
+
+    if (!Number.isFinite(alpha) || alpha <= 0) {
+      throw new Error('原图未成功绘制,请重新拍摄')
+    }
+  }
+}
+
+const assertExportedImage = async (
+  filePath: string,
+  width: number,
+  height: number
+): Promise<void> => {
+  const imageInfo = await runCanvasStep<UniApp.GetImageInfoSuccessData>(
+    '水印图片校验',
+    (resolve, reject) => {
+      uni.getImageInfo({ src: filePath, success: resolve, fail: reject })
+    }
+  )
+  const outputWidth = Number(imageInfo.width)
+  const outputHeight = Number(imageInfo.height)
+
+  if (
+    !Number.isFinite(outputWidth) ||
+    !Number.isFinite(outputHeight) ||
+    Math.abs(outputWidth - width) > 1 ||
+    Math.abs(outputHeight - height) > 1
+  ) {
+    throw new Error('水印图片尺寸异常,请重新拍摄')
+  }
+}
+
 export const renderTaskWatermark = async (options: WatermarkOptions): Promise<string> => {
   const { filePath, canvasId, componentInstance, resize, draw } = options
   const imageInfo = await runCanvasStep<UniApp.GetImageInfoSuccessData>(
@@ -100,7 +162,9 @@ export const renderTaskWatermark = async (options: WatermarkOptions): Promise<st
       context.draw(false, resolve)
     })
 
-    return await runCanvasStep<string>('水印导出', (resolve, reject) => {
+    await assertCanvasPainted(canvasId, width, height, componentInstance)
+
+    const exportedPath = await runCanvasStep<string>('水印导出', (resolve, reject) => {
       uni.canvasToTempFilePath(
         {
           canvasId,
@@ -116,6 +180,9 @@ export const renderTaskWatermark = async (options: WatermarkOptions): Promise<st
         componentInstance
       )
     })
+    await assertExportedImage(exportedPath, width, height)
+
+    return exportedPath
   } finally {
     // 导出后释放大画布,避免多个拍照字段持续占用 iOS 图像内存。
     await resize(1, 1)