Files
threeonecheck_web/uni_modules/uview-plus/components/u-signature/u-signature.vue
2026-06-03 10:16:37 +08:00

562 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="u-signature">
<view class="u-signature__canvas-wrap">
<canvas
class="u-signature__canvas"
:id="canvasId"
:canvas-id="canvasId"
:disable-scroll="true"
@touchstart="touchStart"
@touchmove="touchMove"
@touchend="touchEnd"
:style="{
width: canvasWidth + 'px',
height: canvasHeight + 'px',
background: bgColor
}"
></canvas>
</view>
<view v-if="showToolbar" class="u-signature__toolbar">
<view class="u-signature__toolbar-icons u-flex u-flex-x">
<view class="u-signature__toolbar-icon" @click="undo">
<up-icon name="arrow-left" size="22" :color="pathStack.length === 0 ? '#ccc' : '#999'"></up-icon>
</view>
<view class="u-signature__toolbar-icon" @click="clear">
<up-icon name="trash" size="25" color="#999"></up-icon>
</view>
<view class="u-signature__toolbar-icon" @click="toggleBrushSettings">
<up-icon name="edit-pen" size="25" color="#999"></up-icon>
</view>
<view class="u-signature__toolbar-icon" @click="toggleColorSettings">
<up-icon name="grid" size="24" color="#999"></up-icon>
</view>
<view class="u-signature__toolbar-icon" @click="exportSignature">
<up-icon name="checkmark" size="25" :color="isEmpty ? '#ccc' : '#999'"></up-icon>
</view>
</view>
<!-- 笔画设置 -->
<view v-if="showBrushSettings" class="u-signature__brush-settings">
<view class="u-signature__progress">
<text class="u-signature__progress-label">{{ t("up.signature.penSize") }}:</text>
<up-slider
v-model="lineWidth"
:min="1"
:max="20"
:step="1"
@show-value="true"
:value-show="(lineWidth)"
></up-slider>
</view>
</view>
<!-- 颜色设置 -->
<view v-if="showColorSettings" class="u-signature__color-settings">
<view class="u-signature__color-picker">
<text class="u-signature__color-label">{{ t("up.signature.penColor") }}:</text>
<view class="u-signature__colors">
<view
v-for="(color, index) in presetColors"
:key="index"
class="u-signature__color-item"
:class="{'u-signature__color-item--active': lineColor === color}"
:style="{ backgroundColor: color }"
@click="selectColor(color)"
></view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { t } from '../../libs/i18n'
export default {
name: 'u-signature',
props: {
// 画布宽度
width: {
type: [String, Number],
default: 300
},
// 画布高度
height: {
type: [String, Number],
default: 200
},
// 背景颜色
bgColor: {
type: String,
default: '#ffffff'
},
// 默认笔画颜色
color: {
type: String,
default: '#000000'
},
// 默认笔画粗细
thickness: {
type: [String, Number],
default: 3
},
// 是否显示工具栏
showToolbar: {
type: Boolean,
default: true
}
},
data() {
return {
canvasId: 'u-signature-' + Math.random().toString(36).substr(2, 9),
canvasWidth: 300,
canvasHeight: 200,
lineColor: '#000000',
lineWidth: 3,
isDrawing: false,
pathStack: [], // 存储绘制路径用于回退
currentPath: [], // 当前绘制路径
ctx: null,
isEmpty: true,
presetColors: [
'#000000', // 黑色
'#ff0000', // 红色
'#00ff00', // 绿色
'#0000ff', // 蓝色
'#ffff00', // 黄色
'#00ffff', // 青色
'#ff00ff', // 紫色
'#ffffff' // 白色
],
showBrushSettings: false,
showColorSettings: false,
lastPoint: null, // 保存上一个点的坐标
canvasLeft: 0,
canvasTop: 0
}
},
mounted() {
this.initCanvas()
// #ifndef APP-NVUE
setTimeout(() => { this.updateCanvasOffset() }, 100)
// #endif
},
watch: {
width: {
handler(newVal) {
this.canvasWidth = Number(newVal)
},
immediate: true
},
height: {
handler(newVal) {
this.canvasHeight = Number(newVal)
},
immediate: true
},
color: {
handler(newVal) {
this.lineColor = newVal
},
immediate: true
},
thickness: {
handler(newVal) {
this.lineWidth = Number(newVal)
},
immediate: true
}
},
methods: {
initCanvas() {
// #ifndef APP-NVUE
const ctx = uni.createCanvasContext(this.canvasId, this)
this.ctx = ctx
this.clearCanvas()
// #endif
// #ifdef APP-NVUE
// NVUE环境下的处理
// #endif
},
touchStart(e) {
this.updateCanvasOffset()
if (!this.ctx) return
this.isDrawing = true
this.isEmpty = false
this.currentPath = []
const { x, y } = this.getCanvasPoint(e)
this.ctx.beginPath()
this.ctx.moveTo(x, y)
this.ctx.setLineCap('round')
this.ctx.setLineJoin('round')
this.ctx.setStrokeStyle(this.lineColor)
this.ctx.setLineWidth(this.lineWidth)
// 记录起始点
this.currentPath.push({
x,
y,
type: 'start',
color: this.lineColor,
width: this.lineWidth
})
// 保存上一个点
this.lastPoint = { x, y }
// 阻止默认事件以提高性能
e.preventDefault()
},
touchMove(e) {
if (!this.isDrawing || !this.ctx) return
// 阻止默认事件以提高性能
e.preventDefault()
const { x, y } = this.getCanvasPoint(e)
// 使用更密集的点采样确保线条连贯性
if (this.lastPoint) {
// 计算两点间距离
const distance = Math.sqrt(Math.pow(x - this.lastPoint.x, 2) + Math.pow(y - this.lastPoint.y, 2))
// 根据距离确定插值点数量确保点间距不超过1像素以获得更平滑的线条
const steps = Math.max(1, Math.floor(distance / 1))
// 在两点间插入插值点
for (let i = 1; i <= steps; i++) {
const t = i / steps
const midX = this.lastPoint.x + (x - this.lastPoint.x) * t
const midY = this.lastPoint.y + (y - this.lastPoint.y) * t
this.ctx.lineTo(midX, midY)
this.ctx.stroke()
this.currentPath.push({
x: midX,
y: midY,
type: 'move'
})
}
} else {
this.ctx.lineTo(x, y)
this.ctx.stroke()
this.currentPath.push({
x,
y,
type: 'move'
})
}
this.ctx.draw(true)
// 更新上一个点
this.lastPoint = { x, y }
},
touchEnd(e) {
if (!this.isDrawing || !this.ctx) return
this.isDrawing = false
this.ctx.closePath()
this.lastPoint = null
// 将当前路径加入栈中用于回退
if (this.currentPath.length > 0) {
this.pathStack.push([...this.currentPath])
}
this['$emit']('change', this.pathStack)
},
// 动态更新 Canvas 在当前视口下的实时位置偏移,用于高精度计算
updateCanvasOffset() {
// #ifndef APP-NVUE
const query = uni.createSelectorQuery()
query.select('#' + this.canvasId).boundingClientRect(rect => {
if (rect) {
this.canvasLeft = rect.left
this.canvasTop = rect.top
}
}).exec()
// #endif
},
// 同步获取canvas坐标点兼容处理
getCanvasPoint(e) {
// 🌟 1. 100% 微信真机秘籍:优先读取原生 mpEvent 内的 changedTouches它保存了触发本次笔迹移动的那个最精确的触点
const mpTouch = e.mp && e.mp.changedTouches && e.mp.changedTouches[0] ? e.mp.changedTouches[0] : null
const uniTouch = (e.changedTouches && e.changedTouches[0]) || (e.touches && e.touches[0])
const touch = mpTouch || uniTouch
if (!touch) return { x: 0, y: 0 }
// 🌟 2. 直取微信原生和 Uni-App 规范下的相对坐标 x/y直接就是基于 Canvas 左上角,无需任何复杂 selector 视口偏移计算,永无偏差)
if (touch.x !== undefined && touch.y !== undefined && !isNaN(touch.x) && !isNaN(touch.y)) {
return {
x: touch.x,
y: touch.y
}
}
// 🌟 3. 万一由于 Vue3 层层嵌套导致 touch 丢失了相对坐标,我们再启用绝对定位 fallback 偏移作为高精度备用防护
let x = touch.clientX - this.canvasLeft
let y = touch.clientY - this.canvasTop
if (isNaN(x) || x === undefined) x = 0
if (isNaN(y) || y === undefined) y = 0
return {
x: Math.max(0, x),
y: Math.max(0, y)
}
},
getCanvasPointUnused(e) {
const touch = e.touches[0] || e.changedTouches[0]
if (!touch) return { x: 0, y: 0 }
// 1. 优先使用原生组件的相对坐标
if (touch.x !== undefined && touch.y !== undefined && !isNaN(touch.x) && !isNaN(touch.y)) {
return {
x: touch.x,
y: touch.y
}
}
// 2. 针对 iOS 苹果微信小程序(没有 x/y采用视口绝对坐标减去容器视口偏移量计算
let x = touch.clientX - this.canvasLeft
let y = touch.clientY - this.canvasTop
// 3. 严格数据降级保护,绝不容许 NaN 等非法数值污染画布路径,彻底封死放射状黑墨水
if (isNaN(x) || x === undefined) x = 0
if (isNaN(y) || y === undefined) y = 0
return {
x: Math.max(0, x),
y: Math.max(0, y)
}
},
getCanvasPointOld(e) {
const touch = e.touches[0]
// 移除了高频调用的无用 SelectorQuery 以防止内存泄漏和线程卡顿
// 返回一个包含坐标的对象
return {
x: touch.x,
y: touch.y
}
},
// 选择颜色
selectColor(color) {
this.lineColor = color
},
// 回退操作
undo() {
if (this.pathStack.length === 0) return
// 弹出最后一个路径
this.pathStack.pop()
// 重新绘制
this.redraw()
this['$emit']('change', this.pathStack)
},
// 重新绘制所有路径
redraw() {
this.clearCanvas()
if (this.pathStack.length === 0) {
this.isEmpty = true
return
}
this.isEmpty = false
// #ifndef APP-NVUE
this.pathStack.forEach(path => {
if (path.length === 0) return
this.ctx.beginPath()
this.ctx.setLineCap('round')
this.ctx.setLineJoin('round')
let lastPoint = null
path.forEach((point, index) => {
if (index === 0 && point.type === 'start') {
// 设置起始点样式
this.ctx.setStrokeStyle(point.color)
this.ctx.setLineWidth(point.width)
this.ctx.moveTo(point.x, point.y)
lastPoint = { x: point.x, y: point.y }
} else if (point.type === 'move') {
this.ctx.lineTo(point.x, point.y)
lastPoint = { x: point.x, y: point.y }
}
})
this.ctx.stroke()
this.ctx.draw(true)
})
// #endif
},
// 清空画布
clear() {
this.pathStack = []
this.currentPath = []
this.isEmpty = true
this.lastPoint = null
this.clearCanvas()
this['$emit']('change', this.pathStack)
},
// 清空画布内容
clearCanvas() {
if (!this.ctx) return
// #ifndef APP-NVUE
this.ctx.setFillStyle(this.bgColor)
this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
this.ctx.draw()
// #endif
},
// 导出签名图片
exportSignature() {
if (this.isEmpty) return
// #ifndef APP-NVUE
uni.canvasToTempFilePath({
canvasId: this.canvasId,
fileType: 'png',
quality: 1,
success: (res) => {
this.$emit('confirm', res.tempFilePath)
},
fail: (err) => {
this.$emit('error', err)
}
}, this)
// #endif
// #ifdef APP-NVUE
// NVUE环境下可能需要特殊处理
// #endif
},
// 切换笔画设置显示
toggleBrushSettings() {
this.showBrushSettings = !this.showBrushSettings;
if (this.showBrushSettings) {
this.showColorSettings = false;
}
},
// 切换颜色设置显示
toggleColorSettings() {
this.showColorSettings = !this.showColorSettings;
if (this.showColorSettings) {
this.showBrushSettings = false;
}
},
}
}
</script>
<style lang="scss" scoped>
.u-signature {
display: flex;
flex-direction: column;
&__canvas-wrap {
border: 1px solid #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
&__canvas {
width: 100%;
height: 100%;
}
&__toolbar {
margin-top: 5px;
background-color: #fff;
}
&__toolbar-icons {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1px 0;
// border: 1px solid #e0e0e0;
border-radius: 4px;
}
&__toolbar-icon {
padding: 5px;
}
&__brush-settings,
&__color-settings {
margin-top: 15px;
padding: 1px;
// border: 1px solid #e0e0e0;
border-radius: 4px;
}
&__progress {
&-label {
display: block;
margin-bottom: 10px;
font-size: 14px;
color: #999;
}
}
&__color-picker {
margin-bottom: 10px;
}
&__color-label {
display: block;
margin-bottom: 10px;
font-size: 14px;
color: #999;
}
&__colors {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
}
&__color-item {
width: 30px;
height: 30px;
border-radius: 50%;
border: 2px solid #f0f0f0;
cursor: pointer;
&--active {
border-color: #2979ff;
transform: scale(1.1);
}
}
&__actions {
display: flex;
flex-direction: row;
gap: 10px;
justify-content: center;
}
}
</style>