uniapp vue3手搓签名组件

1 阅读4分钟

懒得解释,直接就可以用

    <div class="container">
        <CustomNavbar title="签名"></CustomNavbar>
        <div class="content">
            <div class="orientation-tip">
                <i class="fas fa-mobile-alt"></i> 横屏模式下书写体验更佳
            </div>

            <div class="signature-container">
                <div class="canvas-section">
                    <div class="canvas-wrapper">
                        <canvas canvas-id="signatureCanvas" id="signatureCanvas" class="signature-canvas"
                            disable-scroll="true" @touchstart="handleTouchStart" @touchmove="handleTouchMove"
                            @touchend="handleTouchEnd"></canvas>
                        <div class="performance-indicator">
                            FPS: {{ fps }} | 延迟: {{ latency }}ms | 点数: {{ pointCount }}
                        </div>
                        <div class="placeholder" v-if="!hasSignature">
                            <i class="fas fa-pen-nib" style="font-size: 40px; margin-bottom: 10px;"></i>
                            <div>请在此处签名</div>
                            <div style="font-size: 14px; margin-top: 10px;">笔画丝滑无偏移</div>
                        </div>
                    </div>

                    <div class="controls">
                        <button class="btn btn-clear" @click="clearSignature">
                            <i class="fas fa-eraser"></i> 清除
                        </button>
                        <button class="btn btn-undo" @click="undo" :disabled="!canUndo">
                            <i class="fas fa-undo"></i> 撤销
                        </button>
                        <button class="btn btn-save" @click="saveSignature" :disabled="!hasSignature">
                            <i class="fas fa-save"></i> 保存
                        </button>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script setup>
import CustomNavbar from '@/components/custom-navbar.vue'

import { ref, onMounted, onUnmounted, nextTick } from 'vue'

const hasSignature = ref(false)
const canUndo = ref(false)
const signatureDataUrl = ref('')
const penWidth = ref(6)
const fps = ref(0)
const latency = ref(0)
const pointCount = ref(0)
const canvasWidth = ref(0)
const canvasHeight = ref(0)

// 高性能绘图变量
let ctx = null
let isDrawing = false
let paths = []
let currentPath = null
let lastRenderTime = 0
let fpsCounter = 0
let lastFpsUpdate = Date.now()
let lastPoints = []

// 性能优化配置
const CONFIG = {
    THROTTLE_DELAY: 4,
    USE_BEZIER: true,
    BATCH_DRAW: true
}

onMounted(() => {
    initCanvas()
    startFpsMonitor()
    // 监听横竖屏变化
    uni.onWindowResize((res) => {
        setTimeout(() => {
            initCanvasSize()
            redrawCanvas()
        }, 300)
    })
})

onUnmounted(() => {
    paths = []
    currentPath = null
    uni.offWindowResize()
})

// 初始化 Canvas 尺寸
const initCanvasSize = () => {
    const systemInfo = uni.getSystemInfoSync()
    const isLandscape = systemInfo.windowWidth > systemInfo.windowHeight

    if (isLandscape) {
        // 横屏模式 - 使用窗口宽度
        canvasWidth.value = systemInfo.windowWidth - 50 // 减去padding
        canvasHeight.value = systemInfo.windowHeight - 200 // 减去其他元素高度
    } else {
        // 竖屏模式
        canvasWidth.value = systemInfo.windowWidth - 50
        canvasHeight.value = 400
    }

    console.log('Canvas尺寸:', canvasWidth.value, canvasHeight.value)
}

// 初始化 Canvas
const initCanvas = () => {
    initCanvasSize()

    // 使用nextTick确保DOM更新后再创建canvas上下文
    nextTick(() => {
        ctx = uni.createCanvasContext('signatureCanvas', this)

        // 设置画布实际像素尺寸
        const query = uni.createSelectorQuery().in(this)
        query.select('#signatureCanvas').boundingClientRect(res => {
            if (res) {
                console.log('Canvas元素尺寸:', res.width, res.height)

                // 设置canvas实际绘制尺寸
                ctx.width = res.width
                ctx.height = res.height

                // 设置高性能绘制参数
                ctx.lineWidth = penWidth.value
                ctx.lineCap = 'round'
                ctx.lineJoin = 'round'
                ctx.strokeStyle = '#2c3e50'

                // 预绘制空白画布
                redrawCanvas()
            }
        }).exec()
    })
}

// 高性能触摸开始
const handleTouchStart = (e) => {
    const touch = e.touches[0]
    const startTime = Date.now()

    isDrawing = true
    hasSignature.value = true

    // 开始新路径
    currentPath = {
        points: [{ x: touch.x, y: touch.y, t: startTime }],
        color: '#2c3e50',
        width: penWidth.value
    }

    lastPoints = [{ x: touch.x, y: touch.y, t: startTime }]

    // 立即开始绘制
    ctx.beginPath()
    ctx.moveTo(touch.x, touch.y)
    ctx.stroke()
    ctx.draw(true)

    pointCount.value++
}

// 高性能触摸移动
const handleTouchMove = (e) => {
    if (!isDrawing || !currentPath) return

    const currentTime = Date.now()

    // 节流控制
    if (currentTime - lastRenderTime < CONFIG.THROTTLE_DELAY) {
        return
    }

    const touch = e.touches[0]
    const newPoint = { x: touch.x, y: touch.y, t: currentTime }

    // 添加点到当前路径
    currentPath.points.push(newPoint)
    lastPoints.push(newPoint)

    // 保持最近3个点用于贝塞尔计算
    if (lastPoints.length > 3) {
        lastPoints.shift()
    }

    // 高性能绘制
    if (CONFIG.USE_BEZIER && lastPoints.length >= 3) {
        drawBezierCurve(lastPoints)
    } else {
        drawStraightLine(lastPoints)
    }

    lastRenderTime = currentTime
    pointCount.value = currentPath.points.length
    latency.value = currentTime - e.timeStamp
}

// 绘制贝塞尔曲线
const drawBezierCurve = (points) => {
    if (points.length < 3) return

    const p0 = points[0]
    const p1 = points[1]
    const p2 = points[2]

    const cp1x = p1.x + (p2.x - p0.x) / 4
    const cp1y = p1.y + (p2.y - p0.y) / 4

    ctx.beginPath()
    ctx.moveTo(p1.x, p1.y)
    ctx.quadraticCurveTo(cp1x, cp1y, p2.x, p2.y)
    ctx.stroke()
    ctx.draw(true)
}

// 绘制直线
const drawStraightLine = (points) => {
    if (points.length < 2) return

    const lastPoint = points[points.length - 2]
    const currentPoint = points[points.length - 1]

    ctx.beginPath()
    ctx.moveTo(lastPoint.x, lastPoint.y)
    ctx.lineTo(currentPoint.x, currentPoint.y)
    ctx.stroke()
    ctx.draw(true)
}

// 触摸结束
const handleTouchEnd = () => {
    if (!isDrawing || !currentPath) return

    isDrawing = false

    if (currentPath.points.length > 1) {
        paths.push({ ...currentPath })
        canUndo.value = paths.length > 0
    }

    currentPath = null
    lastPoints = []
}

// 清除签名
const clearSignature = () => {
    ctx.clearRect(0, 0, 10000, 10000)
    ctx.draw(true)
    hasSignature.value = false
    canUndo.value = false
    signatureDataUrl.value = ''
    paths = []
    pointCount.value = 0
}

// 撤销上一步
const undo = () => {
    if (paths.length === 0) return

    paths.pop()
    canUndo.value = paths.length > 0
    hasSignature.value = paths.length > 0

    redrawCanvas()
}

// 高性能重绘画布
const redrawCanvas = () => {
    ctx.clearRect(0, 0, 10000, 10000)

    paths.forEach(path => {
        if (path.points.length < 2) return

        ctx.lineWidth = path.width
        ctx.strokeStyle = path.color
        ctx.beginPath()

        if (path.points.length === 2) {
            ctx.moveTo(path.points[0].x, path.points[0].y)
            ctx.lineTo(path.points[1].x, path.points[1].y)
        } else {
            ctx.moveTo(path.points[0].x, path.points[0].y)
            for (let i = 1; i < path.points.length; i++) {
                ctx.lineTo(path.points[i].x, path.points[i].y)
            }
        }

        ctx.stroke()
    })

    ctx.draw(true)
}

// 保存签名
const saveSignature = () => {
    if (paths.length === 0) return

    uni.showLoading({ title: '生成中...' })

    setTimeout(() => {
        uni.canvasToTempFilePath({
            canvasId: 'signatureCanvas',
            quality: 1,
            success: (res) => {
                convertToBase64(res.tempFilePath).then(base64Data => {
                    uni.hideLoading()
                    signatureDataUrl.value = res.tempFilePath

                    const pages = getCurrentPages()
                    const prevPage = pages[pages.length - 2]

                    if (prevPage && prevPage.$vm) {
                        prevPage.$vm.onBackWithParams({
                            data: base64Data
                        })
                    }

                    uni.navigateBack()
                }).catch(err => {
                    uni.hideLoading()
                    console.error('转换为base64失败:', err)
                    uni.showToast({
                        title: '保存失败',
                        icon: 'none'
                    })
                })
            },
            fail: (err) => {
                uni.hideLoading()
                console.error('保存签名失败:', err)
                uni.showToast({
                    title: '保存失败',
                    icon: 'none'
                })
            }
        })
    }, 100)
}

// 将图像文件转换为base64
const convertToBase64 = (filePath) => {
    return new Promise((resolve, reject) => {
        uni.getFileSystemManager().readFile({
            filePath: filePath,
            encoding: 'base64',
            success: (res) => {
                const base64Data = 'data:image/png;base64,' + res.data
                resolve(base64Data)
            },
            fail: (error) => {
                reject(error)
            }
        })
    })
}

// FPS监控
const startFpsMonitor = () => {
    const updateFps = () => {
        fpsCounter++
        const now = Date.now()
        if (now - lastFpsUpdate >= 1000) {
            fps.value = fpsCounter
            fpsCounter = 0
            lastFpsUpdate = now
        }
        requestAnimationFrame(updateFps)
    }
    updateFps()
}
</script>
<style scoped lang="scss">
.container {
    width: 100vw;
    height: 100vh;
    background: rgba(255, 255, 255, 0.98);
    overflow: hidden;
}

.content {
    padding: 25px;
    height: calc(100% - 80rpx);
    display: flex;
    flex-direction: column;
}

.orientation-tip {
    background: linear-gradient(to right, #ff7e5f, #feb47b);
    color: white;
    padding: 12px;
    text-align: center;
    font-size: 14px;
    border-radius: 8px;
    margin-bottom: 20px;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
}

.signature-container {
    flex: 1;
    display: flex;
    flex-direction: column;
}

.canvas-section {
    flex: 1;
    display: flex;
    flex-direction: column;
}

.canvas-wrapper {
    position: relative;
    flex: 1;
    width: 100%;
    border: 3px dashed #a0aec0;
    border-radius: 12px;
    background: #f8fafc;
    overflow: hidden;
    -webkit-user-select: none;
    user-select: none;
    -webkit-touch-callout: none;
    -webkit-tap-highlight-color: transparent;
}

.signature-canvas {
    width: 100%;
    height: 100%;
    background: white;
    display: block;
    touch-action: none;
}

.placeholder {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    color: #a0aec0;
    font-size: 20px;
    text-align: center;
    pointer-events: none;
    z-index: 1;
}

.performance-indicator {
    position: absolute;
    top: 10px;
    right: 10px;
    background: rgba(0, 0, 0, 0.7);
    color: white;
    padding: 5px 10px;
    border-radius: 4px;
    font-size: 12px;
    z-index: 2;
}

.controls {
    display: flex;
    justify-content: space-around;
    margin: 20rpx 0;
    flex-shrink: 0;
}

.btn {
    border-radius: 10rpx;
    font-size: 14rpx;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.3s;
    display: flex;
    align-items: center;
    justify-content: center;
    border: 1rpx solid rgba(238, 238, 238, 0.5);
    color: #666;
}

.btn:active {
    transform: translateY(2rpx);
}

button {
    padding: 0;
    background: none !important;
    border: none !important;
    padding: 0 !important;
    margin: 0 !important;
    line-height: normal !important;
    border-radius: 0 !important;
    font-size: inherit !important;
    color: inherit !important;

    &::after {
        border: none !important;
    }
}

/* 横屏样式优化 */
@media screen and (orientation: landscape) {
    .container {
        max-width: 100vw;
    }

    .content {
        padding: 15px;
    }

    .canvas-wrapper {
        height: 100%;
        min-height: auto;
    }

    .orientation-tip {
        display: none;
    }

    .controls {
        margin-top: 15px;
        padding: 0 10px;
    }
}

/* 竖屏样式 */
@media screen and (orientation: portrait) {
    .canvas-wrapper {
        height: 400px;
    }
}

/* 防止iOS橡皮筋效果 */
body {
    position: fixed;
    width: 100%;
    height: 100%;
    overflow: hidden;
}
</style>