yuanmingze пре 1 месец
родитељ
комит
df3a6a2bd3

+ 278 - 311
src/pages-common/signature/components/SignaturePad.vue

@@ -1,45 +1,66 @@
 <template>
   <view class="signature-body">
-    <!-- 左侧操作栏 -->
     <view class="sidebar">
       <view class="btns">
         <view class="button" :class="{ 'is-disabled': generating }" @click="handleClear">
-          重签
+          <text class="button-label">重签</text>
         </view>
         <view class="button active" :class="{ 'is-disabled': generating }" @click="handleSubmit">
-          {{ generating ? '生成中' : '完成签名' }}
+          <text class="button-label">{{ generating ? '生成中' : '完成签名' }}</text>
         </view>
       </view>
     </view>
 
-    <!-- 签名画布 -->
     <view class="canvas">
+      <!-- 导出画布留在视口内,由白色签名画布覆盖,避免原生画布在屏幕外不渲染。 -->
+      <canvas
+        id="signature-export-canvas"
+        canvas-id="signature-export-canvas"
+        class="export-canvas"
+        :type="canvasType"
+        :style="exportCanvasStyle"
+        @error="handleCanvasError"
+      />
       <canvas
         id="signature-canvas"
         canvas-id="signature-canvas"
+        class="drawing-canvas"
+        :type="canvasType"
         disable-scroll
         @touchstart="onTouchStart"
         @touchmove="onTouchMove"
         @touchend="onTouchEnd"
+        @touchcancel="onTouchEnd"
+        @error="handleCanvasError"
       />
-      <text class="signature-text">签 名 区</text>
     </view>
-
-    <!-- 隐藏旋转画布 -->
-    <canvas
-      class="hidden-canvas"
-      id="signature-rotate-canvas"
-      canvas-id="signature-rotate-canvas"
-      :style="rotateCanvasStyle"
-    />
   </view>
 </template>
 
 <script setup lang="ts">
-import { computed, type CSSProperties, getCurrentInstance, nextTick, onMounted, ref } from 'vue'
+import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
+
+import {
+  createCanvas2DSurface,
+  createLegacySurface,
+  createSignatureTasks,
+  drawBackground,
+  drawSegment,
+  drawSignatureForExport,
+  getTouchPoint,
+  hasSignaturePixels,
+  type CanvasBounds,
+  type SignaturePoint,
+  type SignatureSurface,
+} from './signatureCanvas'
 
 const CANVAS_ID = 'signature-canvas'
-const ROTATE_CANVAS_ID = 'signature-rotate-canvas'
+const EXPORT_CANVAS_ID = 'signature-export-canvas'
+const OUTPUT_WIDTH = 300
+let canvasType: '2d' | undefined
+// #ifdef MP-WEIXIN
+canvasType = '2d'
+// #endif
 
 const emit = defineEmits<{
   (e: 'submit', filePath: string): void
@@ -48,350 +69,296 @@ const emit = defineEmits<{
 }>()
 
 const instance = getCurrentInstance()
-
-const ctx = ref<UniApp.CanvasContext | null>(null)
-const canvasWidth = ref(0)
-const canvasHeight = ref(0)
-const rotateCanvasWidth = ref(1)
-const rotateCanvasHeight = ref(1)
-
-const drawing = ref(false)
-const lastPoint = ref<{ x: number; y: number } | null>(null)
-const hasSignature = ref(false)
+const tasks = createSignatureTasks()
 const generating = ref(false)
-
-const rotateCanvasStyle = computed<CSSProperties>(() => ({
-  width: `${rotateCanvasWidth.value}px`,
-  height: `${rotateCanvasHeight.value}px`,
+const outputHeight = ref(150)
+const exportCanvasStyle = computed(() => ({
+  width: `${OUTPUT_WIDTH}px`,
+  height: `${outputHeight.value}px`,
 }))
 
-onMounted(async () => {
-  await nextTick()
-
-  const windowInfo = uni.getWindowInfo()
-  canvasWidth.value = windowInfo.windowWidth
-  canvasHeight.value = windowInfo.windowHeight
-
-  ctx.value = uni.createCanvasContext(CANVAS_ID, instance?.proxy as any)
-
-  drawBackground()
-})
-
-const drawBackground = () => {
-  if (!ctx.value) return
-
-  ctx.value.setFillStyle('#ffffff')
-  ctx.value.fillRect(0, 0, canvasWidth.value, canvasHeight.value)
+// 原生 Canvas/Context 保持普通引用,不能交给 Vue 深层响应式代理。
+let surface: SignatureSurface | undefined
+let exportSurface: SignatureSurface | undefined
+let bounds: CanvasBounds | undefined
+let strokes: SignaturePoint[][] = []
+let currentStroke: SignaturePoint[] | undefined
+let initializing: Promise<void> | undefined
+let disposed = false
+let canvasRevision = 0
+
+type CanvasNodeInfo = UniApp.NodeInfo & { node?: WechatMiniprogram.Canvas }
+type TouchEvent = Parameters<typeof getTouchPoint>[0]
+
+const queryCanvas = (id: string) =>
+  tasks.run<CanvasNodeInfo>('读取画布尺寸', (resolve, reject) => {
+    const query = uni.createSelectorQuery()
+    if (instance?.proxy) query.in(instance.proxy)
+    query
+      .select(`#${id}`)
+      .fields({ node: canvasType === '2d', size: true, rect: true }, (result) => {
+        if (!result || Array.isArray(result) || !result.width || !result.height) {
+          reject(new Error('签名画布未就绪,请点击重签重试'))
+          return
+        }
+        resolve(result)
+      })
+      .exec()
+  })
 
-  ctx.value.save()
-  ctx.value.translate(canvasWidth.value / 2, canvasHeight.value / 2)
-  ctx.value.rotate(Math.PI / 2)
-  ctx.value.setFontSize(60)
-  ctx.value.setFillStyle('rgba(0,0,0,0.06)')
-  ctx.value.setTextAlign('center')
-  ctx.value.fillText('签 名 区', 0, 0)
-  ctx.value.restore()
+const initializeCanvas = async () => {
+  if (initializing) return initializing
+  const revision = ++canvasRevision
+  const isObsolete = () => disposed || revision !== canvasRevision
+  initializing = (async () => {
+    await nextTick()
+    if (isObsolete()) return
+    const info = await queryCanvas(CANVAS_ID)
+    if (isObsolete()) return
+    const measuredBounds = {
+      width: info.width!,
+      height: info.height!,
+      left: info.left ?? 0,
+      top: info.top ?? 0,
+    }
+    outputHeight.value = Math.max(1, Math.round((OUTPUT_WIDTH * info.width!) / info.height!))
+    await nextTick()
+    if (isObsolete()) return
+    const exportInfo = await queryCanvas(EXPORT_CANVAS_ID)
+    if (isObsolete()) return
+
+    let drawingSurface: SignatureSurface
+    let outputSurface: SignatureSurface
+    if (canvasType === '2d') {
+      if (!info.node || !exportInfo.node) {
+        throw new Error('当前微信无法初始化签名画布,请更新微信后重试')
+      }
+      const pixelRatio = uni.getWindowInfo().pixelRatio || 1
+      drawingSurface = createCanvas2DSurface(info.node, info.width!, info.height!, pixelRatio)
+      outputSurface = createCanvas2DSurface(exportInfo.node, OUTPUT_WIDTH, outputHeight.value, 1)
+    } else {
+      drawingSurface = createLegacySurface(CANVAS_ID, instance?.proxy, tasks)
+      outputSurface = createLegacySurface(EXPORT_CANVAS_ID, instance?.proxy, tasks)
+    }
 
-  ctx.value.draw()
+    drawBackground(drawingSurface.context, measuredBounds.width, measuredBounds.height)
+    await drawingSurface.flush(false)
+    if (isObsolete()) return
+    bounds = measuredBounds
+    surface = drawingSurface
+    exportSurface = outputSurface
+  })()
+  try {
+    await initializing
+  } finally {
+    initializing = undefined
+  }
 }
 
-const onTouchStart = (e: any) => {
-  const touch = (e as any).touches?.[0]
-  if (!touch || !ctx.value || generating.value) return
-
-  drawing.value = true
-  lastPoint.value = { x: touch.x, y: touch.y }
+const reportError = (error: unknown) => {
+  if (disposed) return
+  console.error('[SignaturePad]', error)
+  emit('error', error)
 }
 
-const onTouchMove = (e: any) => {
-  if (!drawing.value || !ctx.value || !lastPoint.value || generating.value) return
-
-  const touch = (e as any).touches?.[0]
-  if (!touch) return
+const handleCanvasError = (event: { detail?: { errMsg?: string } }) => {
+  const error = new Error(event.detail?.errMsg || '签名画布异常,请点击重签重试')
+  canvasRevision++
+  tasks.cancel(error)
+  surface = undefined
+  exportSurface = undefined
+  currentStroke = undefined
+  if (!generating.value && !initializing) reportError(error)
+}
 
-  const current = { x: touch.x, y: touch.y }
+onMounted(() => {
+  void initializeCanvas().catch(reportError)
+})
 
-  ctx.value.beginPath()
-  ctx.value.setLineCap('round')
-  ctx.value.setStrokeStyle('#000000')
-  ctx.value.setLineWidth(4)
-  ctx.value.moveTo(lastPoint.value.x, lastPoint.value.y)
-  ctx.value.lineTo(current.x, current.y)
-  ctx.value.stroke()
-  ctx.value.draw(true)
+onBeforeUnmount(() => {
+  disposed = true
+  tasks.cancel(new Error('签名页面已关闭'))
+  if (generating.value) uni.hideLoading()
+  surface = undefined
+  exportSurface = undefined
+})
 
-  hasSignature.value = true
-  lastPoint.value = current
+const onTouchStart = (event: TouchEvent) => {
+  if (!surface || !bounds || generating.value) return
+  const point = getTouchPoint(event, bounds)
+  if (!point) return
+  currentStroke = [point]
+  strokes.push(currentStroke)
 }
 
-const onTouchEnd = () => {
-  drawing.value = false
-  lastPoint.value = null
+const appendPoint = (event: TouchEvent) => {
+  if (!currentStroke || !surface || !bounds || generating.value) return
+  const point = getTouchPoint(event, bounds)
+  const previous = currentStroke[currentStroke.length - 1]
+  if (!point || (point.x === previous.x && point.y === previous.y)) return
+  currentStroke.push(point)
+  try {
+    drawSegment(surface.context, previous, point)
+    // 旧版端提交绘制命令;微信 Canvas 2D 已同步绘制,无需等待 draw 回调。
+    surface.flushStroke()
+  } catch (error) {
+    handleCanvasError({ detail: { errMsg: error instanceof Error ? error.message : undefined } })
+  }
 }
 
-const handleClear = () => {
-  if (!ctx.value || generating.value) return
-
-  ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value)
-  hasSignature.value = false
-  drawBackground()
-  emit('clear')
+const onTouchMove = (event: TouchEvent) => appendPoint(event)
+const onTouchEnd = (event: TouchEvent) => {
+  appendPoint(event)
+  currentStroke = undefined
 }
 
-const finishGeneration = () => {
-  generating.value = false
-  uni.hideLoading()
+const handleClear = async () => {
+  if (generating.value || disposed) return
+  currentStroke = undefined
+  strokes = []
+  surface = undefined
+  exportSurface = undefined
+  try {
+    await initializeCanvas()
+    if (!disposed) emit('clear')
+  } catch (error) {
+    reportError(error)
+  }
 }
 
-const handleSubmit = () => {
-  if (generating.value) return
-
-  if (!ctx.value) {
-    emit('error', new Error('签名画布未就绪'))
+const handleSubmit = async () => {
+  if (generating.value || disposed) return
+  if (!surface || !exportSurface || !bounds) {
+    reportError(new Error('签名画布未就绪,请点击重签重试'))
     return
   }
-
-  if (!hasSignature.value) {
-    emit('error', new Error('请先完成签名'))
+  if (!strokes.some((stroke) => stroke.length > 1)) {
+    reportError(new Error('请先完成签名'))
     return
   }
 
+  currentStroke = undefined
   generating.value = true
   uni.showLoading({ title: '生成签名中', mask: true })
-
-  // 等待最后一笔真正写入视图层后再导出,避免快速提交时拿到空画布。
+  let filePath: string | undefined
+  let failure: unknown
+  const output = exportSurface
+  const revision = canvasRevision
+  const ensureCanvasActive = () => {
+    if (revision !== canvasRevision) throw new Error('签名画布已失效,请点击重签重试')
+  }
   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
-      )
+    // 从实际笔迹重绘横向签名,省去原图导出、图片加载及异步旋转链路。
+    drawSignatureForExport(output.context, bounds, strokes, OUTPUT_WIDTH, outputHeight.value)
+    await output.flush(false)
+    if (disposed) return
+    ensureCanvasActive()
+    const pixels = await output.readPixels(OUTPUT_WIDTH, outputHeight.value)
+    if (disposed) return
+    ensureCanvasActive()
+    if (!hasSignaturePixels(pixels)) throw new Error('签名生成异常,请点击重签重试')
+    filePath = await tasks.run<string>('导出签名图片', (resolve, reject) => {
+      output.exportFile(OUTPUT_WIDTH, outputHeight.value, resolve, reject)
     })
+    ensureCanvasActive()
+    if (!filePath) throw new Error('签名文件为空,请重试')
   } catch (error) {
-    finishGeneration()
-    emit('error', error)
+    failure = error
+  } finally {
+    if (!disposed) {
+      generating.value = false
+      uni.hideLoading()
+    }
   }
+  if (disposed) return
+  // 先关闭本组件 loading,再交给父页面显示“保存中”。
+  if (failure) reportError(failure)
+  else if (filePath) emit('submit', filePath)
 }
+</script>
 
-const waitForRotateCanvasReady = (width: number, height: number): Promise<void> => {
-  return new Promise((resolve, reject) => {
-    const query = uni.createSelectorQuery()
-    if (instance?.proxy) query.in(instance.proxy)
-
-    query
-      .select(`#${ROTATE_CANVAS_ID}`)
-      .boundingClientRect((rect) => {
-        if (
-          !rect ||
-          Array.isArray(rect) ||
-          typeof rect.width !== 'number' ||
-          typeof rect.height !== 'number' ||
-          Math.abs(rect.width - width) > 1 ||
-          Math.abs(rect.height - height) > 1
-        ) {
-          reject(new Error('签名画布尺寸未就绪'))
-          return
-        }
-
-        resolve()
-      })
-      .exec()
-  })
+<style scoped lang="scss">
+.signature-body {
+  display: flex;
+  width: 100%;
+  height: 100vh;
+  overflow: hidden;
+  background: #ffffff;
 }
 
-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
-    )
-  })
+.sidebar {
+  width: 150rpx;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #e5f1fe;
 }
 
-const rotateImage = (src: string) => {
-  uni.getImageInfo({
-    src,
-    async success(info) {
-      const outputWidth = 300
-      const outputHeight = Math.max(1, Math.round((outputWidth * info.width) / info.height))
-
-      try {
-        rotateCanvasWidth.value = outputWidth
-        rotateCanvasHeight.value = outputHeight
-        await nextTick()
-        await waitForRotateCanvasReady(outputWidth, outputHeight)
-
-        const rotateCtx = uni.createCanvasContext(ROTATE_CANVAS_ID, instance?.proxy as any)
-
-        rotateCtx.setFillStyle('#ffffff')
-        rotateCtx.fillRect(0, 0, outputWidth, outputHeight)
-        rotateCtx.translate(outputWidth / 2, outputHeight / 2)
-        rotateCtx.rotate((270 * Math.PI) / 180)
-        rotateCtx.drawImage(
-          info.path || src,
-          -outputHeight / 2,
-          -outputWidth / 2,
-          outputHeight,
-          outputWidth
-        )
-
-        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)
-                },
-              },
-              instance?.proxy as any
-            )
-          } catch (error) {
-            rotateCanvasWidth.value = 1
-            rotateCanvasHeight.value = 1
-            finishGeneration()
-            emit('error', error)
-          }
-        })
-      } catch (err) {
-        rotateCanvasWidth.value = 1
-        rotateCanvasHeight.value = 1
-        finishGeneration()
-        emit('error', err)
-      }
-    },
-    fail(err) {
-      finishGeneration()
-      emit('error', err)
-    },
-  })
+.btns {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
 }
-</script>
 
-<style scoped lang="scss">
-.signature-body {
+.button {
   display: flex;
-  width: 100%;
-  height: 100vh;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  width: 80rpx;
+  height: 250rpx;
+  border-radius: 6rpx;
   background: #ffffff;
+  color: #007bff;
+  font-size: 28rpx;
 
-  .sidebar {
-    width: 150rpx;
-    background-color: #e5f1fe;
-    flex-shrink: 0;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-
-    .btns {
-      display: flex;
-      gap: 100rpx;
-      transform: rotate(90deg);
-
-      .button {
-        width: 250rpx;
-        height: 80rpx;
-        line-height: 80rpx;
-        text-align: center;
-        border-radius: 6rpx;
-        background-color: #ffffff;
-        color: #007bff;
-        font-size: 28rpx;
-      }
+  + .button {
+    margin-top: 100rpx;
+  }
 
-      .button.is-disabled {
-        opacity: 0.55;
-      }
+  &.active {
+    background: #4b9ef2;
+    color: #ffffff;
+  }
 
-      .button.active {
-        background-color: #4b9ef2;
-        color: #ffffff;
-      }
-    }
+  &.is-disabled {
+    opacity: 0.55;
   }
+}
 
-  .canvas {
-    flex: 1;
-    position: relative;
-    overflow: hidden;
+.button-label {
+  display: block;
+  flex-shrink: 0;
+  width: 250rpx;
+  height: 80rpx;
+  line-height: 80rpx;
+  text-align: center;
+  white-space: nowrap;
+  transform: rotate(90deg);
+}
 
-    canvas {
-      width: 100%;
-      height: 100%;
-      display: block;
-    }
+.canvas {
+  flex: 1;
+  min-width: 0;
+  position: relative;
+  overflow: hidden;
+}
 
-    .signature-text {
-      position: absolute;
-      inset: 0;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      transform: rotate(90deg);
-      font-size: 80rpx;
-      color: rgba(208, 216, 242, 0.3);
-      pointer-events: none;
-      user-select: none;
-    }
-  }
+.drawing-canvas {
+  position: absolute;
+  left: 0;
+  top: 0;
+  z-index: 1;
+  width: 100%;
+  height: 100%;
+  background: #ffffff;
+}
 
-  .hidden-canvas {
-    position: fixed;
-    left: -9999px;
-    top: -9999px;
-    pointer-events: none;
-  }
+.export-canvas {
+  position: absolute;
+  left: 0;
+  top: 0;
+  pointer-events: none;
 }
 </style>

+ 284 - 0
src/pages-common/signature/components/signatureCanvas.ts

@@ -0,0 +1,284 @@
+export interface SignaturePoint {
+  x: number
+  y: number
+}
+
+export interface CanvasBounds {
+  width: number
+  height: number
+  left: number
+  top: number
+}
+
+type SignatureContext = Pick<
+  UniApp.CanvasContext,
+  | 'setFillStyle'
+  | 'setStrokeStyle'
+  | 'setLineWidth'
+  | 'setLineCap'
+  | 'setLineJoin'
+  | 'setFontSize'
+  | 'setTextAlign'
+  | 'setTextBaseline'
+  | 'fillRect'
+  | 'fillText'
+  | 'save'
+  | 'restore'
+  | 'translate'
+  | 'scale'
+  | 'rotate'
+  | 'beginPath'
+  | 'moveTo'
+  | 'lineTo'
+  | 'stroke'
+>
+
+export interface SignatureSurface {
+  context: SignatureContext
+  flush: (reserve: boolean) => Promise<void>
+  flushStroke: () => void
+  readPixels: (width: number, height: number) => Promise<ArrayLike<number>>
+  exportFile: (
+    width: number,
+    height: number,
+    resolve: (path: string) => void,
+    reject: (error: unknown) => void
+  ) => void
+}
+
+// 每个异步步骤都必须结束;超时/离页后忽略旧回调,避免误提交或影响下次重试。
+export const createSignatureTasks = (timeoutMs = 8000) => {
+  const pending = new Set<(error: Error) => void>()
+  return {
+    run<T>(
+      stage: string,
+      action: (resolve: (value: T) => void, reject: (error: unknown) => void) => void
+    ): Promise<T> {
+      return new Promise((resolve, reject) => {
+        let settled = false
+        const finish = (callback: () => void) => {
+          if (settled) return
+          settled = true
+          clearTimeout(timer)
+          pending.delete(cancel)
+          callback()
+        }
+        const fail = (error: unknown) => {
+          const detail =
+            error instanceof Error ? error.message : (error as { errMsg?: string })?.errMsg
+          finish(() => reject(new Error(`${stage}失败:${detail || '请重试'}`)))
+        }
+        const cancel = (error: Error) => finish(() => reject(error))
+        const timer = setTimeout(() => cancel(new Error(`${stage}超时,请重试`)), timeoutMs)
+        pending.add(cancel)
+        try {
+          action((value) => finish(() => resolve(value)), fail)
+        } catch (error) {
+          fail(error)
+        }
+      })
+    },
+    cancel(error: Error) {
+      for (const cancel of pending) cancel(error)
+    },
+  }
+}
+
+export const createCanvas2DSurface = (
+  canvas: WechatMiniprogram.Canvas,
+  width: number,
+  height: number,
+  pixelRatio: number
+): SignatureSurface => {
+  canvas.width = Math.max(1, Math.round(width * pixelRatio))
+  canvas.height = Math.max(1, Math.round(height * pixelRatio))
+  const ctx = canvas.getContext('2d')
+  // 使用实际取整后的比例,触摸坐标始终为 CSS 像素。
+  ctx.scale(canvas.width / width, canvas.height / height)
+  const context: SignatureContext = {
+    setFillStyle: (value) => {
+      ctx.fillStyle = value as string
+    },
+    setStrokeStyle: (value) => {
+      ctx.strokeStyle = value as string
+    },
+    setLineWidth: (value) => {
+      ctx.lineWidth = value
+    },
+    setLineCap: (value) => {
+      ctx.lineCap = value
+    },
+    setLineJoin: (value) => {
+      ctx.lineJoin = value
+    },
+    setFontSize: (value) => {
+      ctx.font = `${value}px sans-serif`
+    },
+    setTextAlign: (value) => {
+      ctx.textAlign = value
+    },
+    setTextBaseline: (value) => {
+      ctx.textBaseline = value === 'normal' ? 'alphabetic' : value
+    },
+    fillRect: ctx.fillRect.bind(ctx),
+    fillText: ctx.fillText.bind(ctx),
+    save: ctx.save.bind(ctx),
+    restore: ctx.restore.bind(ctx),
+    translate: ctx.translate.bind(ctx),
+    scale: ctx.scale.bind(ctx),
+    rotate: ctx.rotate.bind(ctx),
+    beginPath: ctx.beginPath.bind(ctx),
+    moveTo: ctx.moveTo.bind(ctx),
+    lineTo: ctx.lineTo.bind(ctx),
+    stroke: ctx.stroke.bind(ctx),
+  }
+  return {
+    context,
+    flush: async () => {},
+    flushStroke: () => {},
+    readPixels: async (width, height) => ctx.getImageData(0, 0, width, height).data,
+    exportFile: (width, height, resolve, reject) => {
+      wx.canvasToTempFilePath({
+        canvas,
+        x: 0,
+        y: 0,
+        width,
+        height,
+        destWidth: width,
+        destHeight: height,
+        fileType: 'png',
+        success: (result) => resolve(result.tempFilePath),
+        fail: reject,
+      })
+    },
+  }
+}
+
+// 非微信端保留 uni-app Canvas API,导出同样从笔迹重绘,不再加载中间图片。
+export const createLegacySurface = (
+  canvasId: string,
+  instance: unknown,
+  tasks: ReturnType<typeof createSignatureTasks>
+): SignatureSurface => {
+  const context = uni.createCanvasContext(canvasId, instance)
+  return {
+    context,
+    flush: (reserve) => tasks.run<void>('绘制签名', (resolve) => context.draw(reserve, resolve)),
+    flushStroke: () => context.draw(true),
+    readPixels: (width, height) =>
+      tasks.run('校验签名图片', (resolve, reject) => {
+        uni.canvasGetImageData(
+          {
+            canvasId,
+            x: 0,
+            y: 0,
+            width,
+            height,
+            success: (result) => resolve(result.data),
+            fail: reject,
+          },
+          instance
+        )
+      }),
+    exportFile: (width, height, resolve, reject) => {
+      uni.canvasToTempFilePath(
+        {
+          canvasId,
+          x: 0,
+          y: 0,
+          width,
+          height,
+          destWidth: width,
+          destHeight: height,
+          fileType: 'png',
+          quality: 1,
+          success: (result) => resolve(result.tempFilePath),
+          fail: reject,
+        },
+        instance
+      )
+    },
+  }
+}
+
+export const drawBackground = (ctx: SignatureContext, width: number, height: number) => {
+  ctx.setFillStyle('#ffffff')
+  ctx.fillRect(0, 0, width, height)
+  ctx.save()
+  ctx.translate(width / 2, height / 2)
+  ctx.rotate(Math.PI / 2)
+  ctx.setFontSize(Math.min(40, height / 8))
+  ctx.setFillStyle('rgba(0,0,0,0.06)')
+  ctx.setTextAlign('center')
+  ctx.setTextBaseline('middle')
+  ctx.fillText('签 名 区', 0, 0)
+  ctx.restore()
+}
+
+export const drawSegment = (ctx: SignatureContext, from: SignaturePoint, to: SignaturePoint) => {
+  ctx.beginPath()
+  ctx.setLineCap('round')
+  ctx.setLineJoin('round')
+  ctx.setStrokeStyle('#000000')
+  ctx.setLineWidth(4)
+  ctx.moveTo(from.x, from.y)
+  ctx.lineTo(to.x, to.y)
+  ctx.stroke()
+}
+
+export const drawSignatureForExport = (
+  ctx: SignatureContext,
+  source: CanvasBounds,
+  strokes: SignaturePoint[][],
+  width: number,
+  height: number
+) => {
+  ctx.setFillStyle('#ffffff')
+  ctx.fillRect(0, 0, width, height)
+  ctx.save()
+  try {
+    ctx.translate(0, height)
+    ctx.scale(width / source.height, height / source.width)
+    ctx.rotate(-Math.PI / 2)
+    drawBackground(ctx, source.width, source.height)
+    for (const stroke of strokes) {
+      for (let index = 1; index < stroke.length; index++) {
+        drawSegment(ctx, stroke[index - 1], stroke[index])
+      }
+    }
+  } finally {
+    ctx.restore()
+  }
+}
+
+export const hasSignaturePixels = (pixels: ArrayLike<number>) => {
+  for (let index = 0; index + 3 < pixels.length; index += 4) {
+    if (
+      pixels[index + 3] > 0 &&
+      pixels[index] < 160 &&
+      pixels[index + 1] < 160 &&
+      pixels[index + 2] < 160
+    )
+      return true
+  }
+  return false
+}
+
+interface SignatureTouch {
+  x?: number
+  y?: number
+  clientX?: number
+  clientY?: number
+}
+
+export const getTouchPoint = (
+  event: { touches?: SignatureTouch[]; changedTouches?: SignatureTouch[] },
+  bounds: CanvasBounds
+): SignaturePoint | undefined => {
+  const touch = event.touches?.[0] || event.changedTouches?.[0]
+  if (!touch) return
+  const x = touch.x ?? (touch.clientX == null ? NaN : touch.clientX - bounds.left)
+  const y = touch.y ?? (touch.clientY == null ? NaN : touch.clientY - bounds.top)
+  if (!Number.isFinite(x) || !Number.isFinite(y)) return
+  return { x: Math.max(0, Math.min(bounds.width, x)), y: Math.max(0, Math.min(bounds.height, y)) }
+}