Explorar o código

feat(DOC-2026-11-09-signature-canvas2d-rewrite): 手签名改为竖屏直签 + canvas 2d

S4-1 signature.vue:弃用强制横屏(删除 enterLandscape/restorePortrait 及
onLoad/onUnload 调用、H5 降级提示 h5DegradeHint 与 .rotate-hint),保留竖屏
computeCanvasHeight(windowHeight 长边 - 固定栏),任何机型进入即竖屏可签。
S4-2 hand-signature.vue:canvas 从旧式 uni.createCanvasContext(canvas-id+draw())
重写为 type="2d" + getContext('2d') + fields({node:true}) 接口(参照 RadarChart):
- 触摸坐标优先用 2d 事件相对坐标 touches[0].x/y,不再依赖缓存 rect,笔迹不偏移;
- 绘制改同步 ctx.stroke(),笔迹实时可见(修复"画了看不到");
- 导出改用 uni.canvasToTempFilePath({canvas: _canvasNode,...}),H5 dataURL/
  小程序 fs.readFile base64 既有链路不变;清空改同步 clearRect。
对外契约(props/emit/prefill image 覆盖回显)保持不变,调用方零波及。
Sisyphus hai 4 días
pai
achega
c0db9643ce

+ 165 - 58
cfc-frontend/components/hand-signature/hand-signature.vue

@@ -26,7 +26,8 @@
     </view>
     <view class="signature-canvas-wrap">
       <canvas
-        canvas-id="hsCanvas"
+        :id="canvasId"
+        type="2d"
         class="signature-canvas"
         :style="'width:100%;height:' + height + 'px;'"
         @touchstart.stop.prevent="onTouchStart"
@@ -53,6 +54,26 @@
 </template>
 
 <script>
+/**
+ * 通用手签名组件(uni-app 小程序/H5 兼容,Vue 2)
+ * 基于新版 canvas type="2d" + getContext('2d') 实现(参照 RadarChart 标准写法):
+ *  - 触摸事件由微信 2d canvas 直接提供相对画布坐标,不再依赖缓存 rect → 笔迹位置准确
+ *  - ctx.stroke() 同步绘制,无旧式 ctx.draw() 的丢帧/不渲染问题 → 笔迹实时可见
+ * Props:
+ *   value          string   已有签名(base64 PNG data URL),用于回显
+ *   strokeColor    string   笔触颜色,默认 '#1a1a1a'
+ *   strokeWidth    number   笔触宽度(逻辑像素),默认 2
+ *   height         number   画布高度(px),默认 200
+ *   requireAgree   boolean  是否要求先勾选同意声明才能确认签名,默认 false
+ *   agreeText      string   同意声明文案,默认 '我已阅读并确认以上内容真实、准确'
+ * Emits:
+ *   confirm      dataUrl: string  画布转 base64 PNG data URL(点击「确认签名」时触发)
+ *   clear                          画布被清空
+ *   agree-change agreed: boolean   同意勾选状态变化(取消勾选时调用方应作废已确认签名)
+ * 用法:
+ *   <hand-signature @confirm="onSign" @clear="onClear" />
+ *   onSign(url) { this.signatureDataUrl = url }
+ */
 export default {
   name: 'HandSignature',
   props: {
@@ -94,7 +115,21 @@ export default {
       agreed: false,
       currentDataUrl: this.value || '',
       savingLive: false,
-      rect: null
+      /** 当前实例唯一 canvas id(多实例时避免 select('#hsCanvas') 命中错误节点,参照 RadarChart) */
+      canvasId: 'hsCanvas_' + (this._uid || 0),
+      /** 2d canvas 节点(导出用) */
+      _canvasNode: null,
+      /** 画布逻辑尺寸与像素比(绘制/导出/清空用) */
+      canvasWidth: 0,
+      canvasHeight: 0,
+      dpr: 1,
+      /** fields rect 返回的画布左上角(触摸坐标兜底换算用) */
+      _rectLeft: 0,
+      _rectTop: 0,
+      /** 组件已销毁标志:mounted 的延迟初始化与重试链需据此停止 */
+      _destroyed: false,
+      /** mounted 延迟初始化定时器引用(beforeDestroy 时清理) */
+      _initTimer: null
     }
   },
   watch: {
@@ -108,70 +143,145 @@ export default {
   },
   mounted: function() {
     var self = this
-    this.ctx = uni.createCanvasContext('hsCanvas', this)
-    if (!this.ctx) {
-      this.ctx = uni.createCanvasContext('hsCanvas')
+    // 等 DOM 布局完成后初始化 2d canvas(带 1 次重试,应对微信小程序首次渲染 canvas 节点延迟)
+    var retryCount = 0
+    var maxRetry = 1
+    function tryInit() {
+      if (self._destroyed) return
+      self.initCanvas(function(success) {
+        if (self._destroyed) return
+        if (!success && retryCount < maxRetry) {
+          retryCount++
+          self._initTimer = setTimeout(tryInit, 100)
+        } else if (!success) {
+          console.log('[HandSignature] 初始化失败,重试 ' + maxRetry + ' 次后放弃', self.canvasId)
+        }
+      })
+    }
+    self._initTimer = setTimeout(tryInit, 100)
+  },
+  beforeDestroy: function() {
+    this._destroyed = true
+    if (this._initTimer) {
+      clearTimeout(this._initTimer)
+      this._initTimer = null
     }
-    // 获取画布实际位置与尺寸(导出/坐标换算用);canvas 可能在 nextTick 后才有布局,失败重试一次
-    this.loadRect()
-    this.$nextTick(function() {
-      if (!self.rect) {
-        setTimeout(function() { self.loadRect() }, 300)
-      }
-    })
   },
   methods: {
-    loadRect: function() {
+    /* ===========================
+     * 初始化(参照 RadarChart 标准 2d 写法)
+     * =========================== */
+    initCanvas: function(cb) {
       var self = this
-      try {
-        // 与全站组件一致:用 .in(this) 将选择器限定到本组件作用域(组件内 canvas 必须带组件实例)
-        var query = uni.createSelectorQuery().in(this)
-        query.select('.signature-canvas').boundingClientRect(function(rect) {
-          if (rect) {
-            self.rect = rect
-            // 有预存签名时清空画布(用空白覆盖),回显交给 image 层
-            if (self.currentDataUrl && !self.hasContent) {
-              var ctx = self.ctx
-              if (ctx) {
-                ctx.setFillStyle('#ffffff')
-                ctx.fillRect(0, 0, rect.width, rect.height)
-                ctx.draw(true)
-              }
+      uni.createSelectorQuery().in(this)
+        .select('#' + this.canvasId)
+        .fields({ node: true, size: true, rect: true })
+        .exec(function(res) {
+          // 兜底:回调内任何异常都必须走 cb(false),否则 mounted 的重试机制永远不触发
+          try {
+            if (self._destroyed) return
+            if (!res || !res[0]) {
+              console.log('[HandSignature] 查询失败: 未找到 canvas 节点', self.canvasId)
+              if (cb) cb(false)
+              return
+            }
+            var info = res[0]
+            if (!info.node) {
+              console.log('[HandSignature] node 为空: canvas type=2d 节点获取失败', self.canvasId)
+              if (cb) cb(false)
+              return
+            }
+
+            var canvas = info.node
+            var width = info.width || 0
+            var height = info.height || 0
+            // size 返回 0(flex 塌陷)时降级用 fields rect 尺寸
+            if ((!width || !height) && info.rect && info.rect.width) {
+              width = info.rect.width
+              height = info.rect.height
+            }
+            if (!width || !height) {
+              // 兜底:canvas 宽度按满屏换算,高度按 height prop
+              console.log('[HandSignature] 查询尺寸为 0,改用窗口宽度兜底', self.canvasId)
+              var winWidth = 375
+              try {
+                var winInfo = uni.getWindowInfo()
+                winWidth = winInfo.windowWidth || 375
+              } catch (e2) {}
+              width = Math.round(winWidth)
+              height = Math.round(self.height || 200)
+              console.log('[HandSignature] 兜底尺寸: ' + width + 'x' + height, self.canvasId)
+            }
+
+            var pixelRatio = 2
+            try {
+              var system = uni.getWindowInfo()
+              pixelRatio = system.pixelRatio || 2
+            } catch (e) {
+              console.log('[HandSignature] uni.getWindowInfo() 不可用,使用默认 pixelRatio=2', e)
             }
+
+            canvas.width = width * pixelRatio
+            canvas.height = height * pixelRatio
+
+            var ctx = canvas.getContext('2d')
+            if (!ctx) {
+              console.log('[HandSignature] canvas.getContext(2d) 返回 null', self.canvasId)
+              if (cb) cb(false)
+              return
+            }
+            ctx.scale(pixelRatio, pixelRatio)
+
+            self.ctx = ctx
+            self._canvasNode = canvas
+            self.canvasWidth = width
+            self.canvasHeight = height
+            self.dpr = pixelRatio
+            self._rectLeft = info.left || 0
+            self._rectTop = info.top || 0
+
+            console.log('[HandSignature] 初始化成功', 'size=' + width + 'x' + height, 'canvasId=' + self.canvasId, 'pixelRatio=' + pixelRatio)
+            if (cb) cb(true)
+          } catch (e) {
+            console.log('[HandSignature] initCanvas 回调异常', e)
+            if (cb) cb(false)
           }
-        }).exec()
-      } catch (e) {}
+        })
     },
-    getPos: function(e) {
-      var touch = e.touches && e.touches[0]
-      var cx = touch ? touch.clientX : (e.clientX || 0)
-      var cy = touch ? touch.clientY : (e.clientY || 0)
-      var rect = this.rect
-      if (rect) {
-        return { x: cx - rect.left, y: cy - rect.top }
+
+    /* ===========================
+     * 触摸坐标(type=2d 时微信提供相对画布坐标,不再依赖缓存 rect)
+     * =========================== */
+    _getTouchPos: function(e) {
+      var t = e.touches && e.touches[0]
+      var x = (t && t.x) || 0
+      var y = (t && t.y) || 0
+      // 兜底:2d 事件未提供相对坐标时,用 clientX/clientY - fields rect 左上角换算
+      if (t && t.x === undefined) {
+        x = (t.clientX || 0) - (this._rectLeft || 0)
+        y = (t.clientY || 0) - (this._rectTop || 0)
       }
-      return { x: cx, y: cy }
+      return { x: x, y: y }
     },
     onTouchStart: function(e) {
-      var pos = this.getPos(e)
+      var pos = this._getTouchPos(e)
       this.lastX = pos.x
       this.lastY = pos.y
       this.drawing = true
     },
     onTouchMove: function(e) {
       if (!this.drawing) return
-      var pos = this.getPos(e)
+      var pos = this._getTouchPos(e)
       var ctx = this.ctx
       if (!ctx) return
       ctx.beginPath()
       ctx.moveTo(this.lastX, this.lastY)
       ctx.lineTo(pos.x, pos.y)
-      ctx.setStrokeStyle(this.strokeColor)
-      ctx.setLineWidth(this.strokeWidth)
-      ctx.setLineCap('round')
-      ctx.setLineJoin('round')
+      ctx.strokeStyle = this.strokeColor
+      ctx.lineWidth = this.strokeWidth
+      ctx.lineCap = 'round'
+      ctx.lineJoin = 'round'
       ctx.stroke()
-      ctx.draw(false)
       this.lastX = pos.x
       this.lastY = pos.y
       this.hasContent = true
@@ -197,20 +307,20 @@ export default {
         this.$emit('confirm', this.currentDataUrl)
         return
       }
+      if (!this._canvasNode) {
+        uni.showToast({ title: '签名画布未就绪,请重试', icon: 'none' })
+        return
+      }
       this.savingLive = true
-      var rect = this.rect || {}
-      var w = rect.width || 300
-      var h = rect.height || this.height
-      // 注意第二个参数 this:canvas 位于自定义组件内,必须传入组件实例,
-      // 否则微信小程序按页面级 canvas-id 查找不到组件内 canvas,导出必然失败(签名「不好用」的根因之一)。
+      // 2d 接口传 canvas 节点(不再传 canvasId);第二参数 this 为组件实例
       uni.canvasToTempFilePath({
-        canvasId: 'hsCanvas',
+        canvas: this._canvasNode,
         x: 0,
         y: 0,
-        width: w,
-        height: h,
-        destWidth: w,
-        destHeight: h,
+        width: this.canvasWidth,
+        height: this.canvasHeight,
+        destWidth: this.canvasWidth,
+        destHeight: this.canvasHeight,
         fileType: 'png',
         quality: 1,
         success: function(res) {
@@ -258,13 +368,10 @@ export default {
       this.savingLive = false
     },
     handleClear: function() {
-      var self = this
       if (this.savingLive) return
       var ctx = this.ctx
       if (ctx) {
-        var rect = this.rect || {}
-        ctx.clearRect(0, 0, rect.width || 600, rect.height || this.height)
-        ctx.draw(true)
+        ctx.clearRect(0, 0, this.canvasWidth || 300, this.canvasHeight || this.height)
       }
       this.hasContent = false
       this.hasDrawn = false

+ 6 - 87
cfc-frontend/pages/signature/signature.vue

@@ -1,17 +1,12 @@
 <template>
   <view class="signature-page">
-    <!-- H5 降级提示:不支持 screen.orientation.lock 时提示横置设备(不报错) -->
-    <view v-if="h5DegradeHint" class="rotate-hint">
-      <text class="rotate-hint-text">🔄 请横置设备以获得最佳签名体验</text>
-    </view>
-
     <!-- 顶部标题 -->
     <view class="sig-header">
       <text class="sig-title">✍️ 手写签名</text>
       <text class="sig-subtitle">请在下方空白区域书写签名,完成后点击「确认」</text>
     </view>
 
-    <!-- 画布区(横屏铺满,高度按视口短边计算) -->
+    <!-- 画布区(竖屏全宽,高度 = 视口高 − 固定栏,签名区域充足) -->
     <view class="sig-canvas-area">
       <hand-signature
         ref="signaturePad"
@@ -35,12 +30,12 @@
 
 <script>
 /*
- * 独立横屏全屏签名页(WI-1/WI-2)
+ * 独立竖屏直签签名页(不依赖横屏)
  * - 复用通用 HandSignature 组件(只读复用,不改组件本身)
  * - 入参:prefill 经 utils/signature-bus.js 模块级写入(base64 过长不进 URL);
  *         requireAgree / agreeText 走 URL 参数
- * - 横屏:微信 wx.setPageOrientation({orientation:'landscape'});
- *         H5 screen.orientation.lock('landscape') 包裹 try/catch,不支持时显示「请横置设备」提示
+ * - 竖屏直签:不再调用 wx.setPageOrientation / screen.orientation.lock,
+ *   任何机型(含无法自动横屏的)进入即竖屏可签
  * - 回传:onConfirm 经 getOpenerEventChannel().emit('signatureConfirm', dataUrl) 后 navigateBack;
  *         返回按钮直接 navigateBack(不回传)
  */
@@ -57,10 +52,8 @@ export default {
       requireAgree: false,
       // 同意声明文案
       agreeText: '我已阅读并确认以上内容真实、准确',
-      // 画布高度(px,按横屏视口高度计算)
+      // 画布高度(px,按竖屏视口高度计算)
       canvasHeight: 200,
-      // H5 无法锁横屏时降级提示
-      h5DegradeHint: false,
       // 防止重复 navigateBack
       _backed: false
     }
@@ -76,11 +69,6 @@ export default {
       if (text) this.agreeText = text
     }
     this.canvasHeight = this.computeCanvasHeight()
-    this.enterLandscape()
-  },
-  onUnload: function() {
-    // 离开签名页恢复竖屏,不污染其它页面
-    this.restorePortrait()
   },
   methods: {
     // 兼容运行时未解码/已解码两种情况(含特殊字符时兜底不抛错)
@@ -93,7 +81,7 @@ export default {
       }
       return out
     },
-    // 横屏视口高度(短边)减去顶部标题/底部操作栏/同意声明占位,得到画布可书写高度
+    // 竖屏视口高度(长边,如 812/844)减去顶部标题/底部操作栏/同意声明占位,得到画布可书写高度
     computeCanvasHeight: function() {
       var win = null
       try {
@@ -109,57 +97,6 @@ export default {
       var h = parseInt(vh, 10) - reserved
       return h > 160 ? h : 160
     },
-    // 进入签名页:切横屏(平台分流,条件编译)
-    enterLandscape: function() {
-      // #ifdef MP-WEIXIN
-      try {
-        if (typeof wx !== 'undefined' && typeof wx.setPageOrientation === 'function') {
-          wx.setPageOrientation({
-            orientation: 'landscape'
-          })
-        }
-      } catch (e) {}
-      // #endif
-      // #ifdef H5
-      try {
-        var so = (typeof window !== 'undefined' && window.screen) ? window.screen.orientation : null
-        if (so && typeof so.lock === 'function') {
-          var p = so.lock('landscape')
-          if (p && typeof p.catch === 'function') {
-            var self = this
-            p.catch(function() {
-              // 如非全屏/浏览器不支持 → 降级提示横置设备,不报错
-              self.h5DegradeHint = true
-            })
-          }
-        } else {
-          this.h5DegradeHint = true
-        }
-      } catch (e) {
-        this.h5DegradeHint = true
-      }
-      // #endif
-    },
-    // 离开签名页:恢复竖屏
-    restorePortrait: function() {
-      // #ifdef MP-WEIXIN
-      try {
-        if (typeof wx !== 'undefined' && typeof wx.setPageOrientation === 'function') {
-          wx.setPageOrientation({
-            orientation: 'portrait'
-          })
-        }
-      } catch (e) {}
-      // #endif
-      // #ifdef H5
-      try {
-        var so = (typeof window !== 'undefined' && window.screen) ? window.screen.orientation : null
-        if (so && typeof so.unlock === 'function') {
-          so.unlock()
-        }
-      } catch (e) {}
-      // #endif
-    },
     // HandSignature 确认导出成功(内部「确认签名」按钮已被隐藏,由页面「确认」驱动 handleConfirm 走到这里)
     onConfirm: function(dataUrl) {
       var channel = this.getOpenerEventChannel ? this.getOpenerEventChannel() : null
@@ -215,24 +152,6 @@ export default {
   overflow: hidden;
 }
 
-/* H5 降级提示(绝对定位,不占布局高度) */
-.rotate-hint {
-  position: absolute;
-  top: 12px;
-  left: 12px;
-  right: 12px;
-  z-index: 20;
-  padding: 10px 16px;
-  background: $brand-primary-soft;
-  border: 2rpx solid $brand-primary;
-  border-radius: 16rpx;
-  text-align: center;
-}
-.rotate-hint-text {
-  font-size: calc(26rpx * var(--font-scale, 1));
-  color: $brand-primary;
-}
-
 /* 顶部标题 */
 .sig-header {
   flex-shrink: 0;