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