用户签名功能(canvas)

用户可以在web上签名,然后保存为图片
阿里千问AI查到的

前端HTML代码:


@{
    Layout = null;
}

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>用户签名</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            padding: 20px;
            background-color: #f9f9f9;
        }

        h1 {
            margin-bottom: 20px;
        }

        #signatureCanvas {
            border: 2px solid #333;
            background-color: white;
            cursor: crosshair;
            touch-action: none; /* 禁用触摸滚动/缩放 */
        }

        .controls {
            margin-top: 15px;
        }

        button {
            padding: 10px 20px;
            margin: 0 10px;
            font-size: 16px;
            cursor: pointer;
        }

            button:hover {
                opacity: 0.9;
            }
    </style> 
    <script src="~/lib/jquery/dist/jquery.min.js"></script> 
</head>
<body>

    <h1>请在此签名</h1>
    <canvas id="signatureCanvas" width="500" height="200"></canvas>

    <div class="controls">
        <button id="clearBtn">清除</button>
        <button id="saveBtn">保存签名</button>
    </div>
    <div id="res"></div>
    <script>
  const canvas = document.getElementById('signatureCanvas');
  const ctx = canvas.getContext('2d');
  let isDrawing = false;

  // 设置画笔样式
  ctx.lineWidth = 2;
  ctx.lineCap = 'round';
  ctx.strokeStyle = '#000';

  // 鼠标事件
  canvas.addEventListener('mousedown', startDrawing);
  canvas.addEventListener('mousemove', draw);
  canvas.addEventListener('mouseup', stopDrawing);
  canvas.addEventListener('mouseout', stopDrawing);

  // 触摸事件(移动端)
  canvas.addEventListener('touchstart', handleTouchStart);
  canvas.addEventListener('touchmove', handleTouchMove);
  canvas.addEventListener('touchend', handleTouchEnd);

  function getMousePos(e) {
    const rect = canvas.getBoundingClientRect();
    return {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top
    };
  }

  function getTouchPos(touch) {
    const rect = canvas.getBoundingClientRect();
    return {
      x: touch.clientX - rect.left,
      y: touch.clientY - rect.top
    };
  }

  function startDrawing(e) {
    isDrawing = true;
    const pos = getMousePos(e);
    ctx.beginPath();
    ctx.moveTo(pos.x, pos.y);
  }

  function draw(e) {
    if (!isDrawing) return;
    const pos = getMousePos(e);
    ctx.lineTo(pos.x, pos.y);
    ctx.stroke();
  }

  function stopDrawing() {
    isDrawing = false;
  }

  // 触摸事件处理
  function handleTouchStart(e) {
    e.preventDefault();
    const touch = e.touches[0];
    const pos = getTouchPos(touch);
    isDrawing = true;
    ctx.beginPath();
    ctx.moveTo(pos.x, pos.y);
  }

  function handleTouchMove(e) {
    e.preventDefault();
    if (!isDrawing) return;
    const touch = e.touches[0];
    const pos = getTouchPos(touch);
    ctx.lineTo(pos.x, pos.y);
    ctx.stroke();
  }

  function handleTouchEnd(e) {
    e.preventDefault();
    isDrawing = false;
  }

  // 清除画布
  document.getElementById('clearBtn').addEventListener('click', () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
  });

  // 保存签名
        document.getElementById('saveBtn').addEventListener('click', () => {
            const dataURL = canvas.toDataURL('image/png'); // 获取 base64 字符串

            // 显示图片预览(可选)
            // const img = new Image();
            // img.src = dataURL;
            // document.body.appendChild(img);

            // 发送 AJAX 到后端
            $.ajax({
                url: '/home/index', // 根据实际情况调整 URL 路径
                type: 'POST',
                contentType: 'application/json;charset=UTF-8',
                data: JSON.stringify({
                    Signature: dataURL // 传递给后端的数据
                }),
                success: function (response) {
                    if (response.success) {
                        alert("签名上传成功!\n文件地址:" + response.url);
                        // 可以在此处处理返回的文件URL,比如显示在页面上等
                        $('#res').html("<a href='"+response.url+"'>"+response.url+"</a>")
                    } else {
                        alert("签名上传失败!");
                    }
                },
                error: function (xhr, status, error) {
                    console.error('签名上传时发生错误:', error);
                    alert("签名上传时发生错误,请重试!");
                }
            });
  });
    </script>

</body>
</html>



后端C#代码:


using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
using System.Text.RegularExpressions;
using userqianming.Models;

namespace userqianming.Controllers
{
    public class HomeController : Controller
    {
  
        public IActionResult Index()
        {
            return View();
        }

        [HttpPost]
        public async Task<IActionResult> Index([FromBody] SignatureRequest request)
        {
            if (string.IsNullOrEmpty(request?.Signature))
            {
                return BadRequest("签名数据为空");
            }

            try
            {
                // 提取 base64 部分(去掉 data URL 前缀)
                var base64Data = request.Signature;
                if (base64Data.StartsWith("data:image"))
                {
                    // 使用正则或 Split 移除前缀
                    var match = Regex.Match(base64Data, @"^data:image\/\w+;base64,(.*)$");
                    if (match.Success)
                    {
                        base64Data = match.Groups[1].Value;
                    }
                    else
                    {
                        return BadRequest("无效的 Base64 图片格式");
                    }
                }

                // 解码 Base64
                byte[] imageBytes = Convert.FromBase64String(base64Data);

                // 设置保存路径(例如:wwwroot/signatures/)
                var folderPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "signatures");
                Directory.CreateDirectory(folderPath); // 确保目录存在

                // 生成唯一文件名
                string fileName = $"signature_{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid().ToString("N")[..8]}.png";
                string filePath = Path.Combine(folderPath, fileName);

                // 保存文件
                await System.IO.File.WriteAllBytesAsync(filePath, imageBytes);

                // 可选:返回保存的 URL 或成功信息
                var fileUrl = $"/signatures/{fileName}";
                return Ok(new { success = true, url = fileUrl });
            }
            catch (Exception ex)
            {
                // 记录日志(可集成 ILogger)
                Console.WriteLine($"保存签名失败: {ex.Message}");
                return StatusCode(500, "保存签名时发生错误");
            }
        }

 
    }

    // DTO 用于接收 JSON 数据
    public class SignatureRequest
    {
        public string? Signature { get; set; }
    }
}





2026年3月21日新增的uni app代码,C#后端返回接口改code和data:fileurl


下面是一个完整的 UniApp 手写签名页面示例,使用 Canvas 2D 实现,包含手写、清除、保存功能:```html
<template>
    <view class="container">
        <!-- <view class="header">
            <text class="title">手写签名</text>
        </view> -->

        <view class="canvas-box">
            <canvas type="2d" id="signatureCanvas" canvas-id="signatureCanvas" class="signature-canvas"
                @touchstart="touchStart" @touchmove.stop="touchMove" @touchend="touchEnd"></canvas>
        </view>

        <view class="footer">
            <button class="btn clear-btn" @click="clearSignature">清除</button>
            <button class="btn save-btn" @click="saveSignature">保存签名</button>
        </view>
    </view>
</template>

<script>
    export default {
        data() {
            return {
                canvas: null,
                ctx: null,
                isDrawing: false,
                lastX: 0,
                lastY: 0,
                dpr: 1
            }
        },
        onReady() {
            this.initCanvas()
        },
        methods: {
            // 初始化 Canvas
            async initCanvas() {
                const query = uni.createSelectorQuery().in(this)
                query.select('#signatureCanvas')
                    .fields({
                        node: true,
                        size: true
                    })
                    .exec((res) => {
                        if (!res[0]) return

                        this.canvas = res[0].node
                        this.ctx = this.canvas.getContext('2d')

                        // 处理高清屏
                        this.dpr = uni.getSystemInfoSync().pixelRatio
                        this.canvas.width = res[0].width * this.dpr
                        this.canvas.height = res[0].height * this.dpr
                        this.ctx.scale(this.dpr, this.dpr)

                        // 设置画笔样式
                        this.setPenStyle()
                    })
            },

            // 设置画笔样式
            setPenStyle() {
                this.ctx.lineWidth = 3 // 线条宽度
                this.ctx.strokeStyle = '#000000' // 线条颜色
                this.ctx.lineCap = 'round' // 线条端点样式
                this.ctx.lineJoin = 'round' // 线条连接样式
            },

            // 触摸开始
            touchStart(e) {
                e.preventDefault()
                e.stopPropagation()
                this.isDrawing = true
                const touch = e.touches[0]
                const point = this.getPoint(touch)
                this.lastX = point.x
                this.lastY = point.y

                this.ctx.beginPath()
                this.ctx.moveTo(this.lastX, this.lastY)
            },

            // 触摸移动
            touchMove(e) {
                e.preventDefault()
                e.stopPropagation()
                if (!this.isDrawing) return


                const touch = e.touches[0]
                const point = this.getPoint(touch)

                this.ctx.lineTo(point.x, point.y)
                this.ctx.stroke()

                this.lastX = point.x
                this.lastY = point.y
            },

            // 触摸结束
            touchEnd(e) {
                e.preventDefault()
                e.stopPropagation()
                this.isDrawing = false
            },

            // 获取 Canvas 相对坐标
            getPoint(touch) {
                const query = uni.createSelectorQuery().in(this)
                query.select('#signatureCanvas').boundingClientRect((rect) => {
                    if (!rect) return
                    this.canvasRect = rect
                }).exec();

                var xxx = {
                    x: touch.x,
                    y: touch.y
                };

                return xxx;
            },

            // 清除签名
            clearSignature() {
                if (!this.ctx || !this.canvas) return
                this.ctx.clearRect(0, 0, this.canvas.width / this.dpr, this.canvas.height / this.dpr)
            },

            // 保存签名
            async saveSignature() {
                if (!this.canvas) return

                uni.showLoading({
                    title: "保存中..."
                })
                uni.canvasToTempFilePath({
                    canvasId: "signatureCanvas",
                    fileType: 'png',
                    quality: 1,
                    success: (res) => { 
                        var base64str = res.tempFilePath;

                        //-----调用自己写的后端代码----
                        // var url = getApp().globalData.niunanUrl + "/userqm/index";

                        // uni.request({
                        //     url: url,
                        //     method: "POST",
                        //     data: {
                        //         Signature: base64str
                        //     },
                        //     header: {
                        //         'content-type': 'application/json' //自定义请求头信息
                        //     },
                        //     success: (res2) => {
                        //         uni.hideLoading()
                        //         var jsonobj = res2.data;
                        //         if (jsonobj.code == 1) {
                        //             var imgurl = jsonobj.data.fileurl;
                        //             let pages = getCurrentPages(),
                        //                 prevPage = pages[pages.length - 2]
                        //             // 运行上个页面的函数并传值
                        //             prevPage.$vm.set_qmimg(imgurl)
                        //             prevPage.$vm.$nextTick(() => {
                        //                 uni.navigateBack({
                        //                     delta: 1
                        //                 })
                        //             })
                        //         } else {
                        //             uni.showModal({
                        //                 content: jsonobj.msg,
                        //                 showCancel: false,
                        //             })
                        //         }
                        //     }
                        // });
                        //-----调用自己写的后端代码 end ----
                        //-----调用程工写的后端代码,要传入图片临时路径-----
                        var url = getApp().globalData.baseUrl + '/vdnapi/common/upfile'
                        uni.uploadFile({
                            timeout: 10000, //超时10秒
                            url: url,
                            filePath: res.tempFilePath,
                            name: 'file1',
                            formData: {
                                filetype: 'qianming'
                            },
                            success: (res2) => {
                                uni.hideLoading()
                                var jsonobj = JSON.parse(res2.data);
                                if (jsonobj.code == 1) {
                                    var imgurl = jsonobj.data.fileurl;
                                    let pages = getCurrentPages(),
                                        prevPage = pages[pages.length - 2]
                                    // 运行上个页面的函数并传值
                                    prevPage.$vm.set_qmimg(imgurl)
                                    prevPage.$vm.$nextTick(() => {
                                        uni.navigateBack({
                                            delta: 1
                                        })
                                    })
                                } else {
                                    uni.showModal({
                                        content: jsonobj.msg,
                                        showCancel: false,
                                    })
                                }
                            },
                            fail: (res3) => {
                                uni.showModal({
                                    content: '上传出错:' + JSON.stringify(res3),
                                    showCancel: false,
                                })
                            }
                        });
                        //-----调用程工写的后端代码,要传入图片临时路径 end-----
                    },
                    fail: (err) => {
                        console.log(err)
                        uni.showToast({
                            title: '生成图片失败',
                            icon: 'error'
                        })
                    }
                })
            },

            /**
             * base64转临时文件路径(兼容App端和H5端)
             * @param {String} base64 - base64图片字符串
             * @returns {Promise<String>} 临时文件路径
             */
            base64ToTempFilePath(base64) {
                return new Promise((resolve, reject) => {
                    // 去除base64头部信息
                    const base64Data = base64.replace(/^data:image\/\w+;base64,/, '');

                    // #ifdef APP-PLUS
                    // App端处理:使用plus.io将base64写入临时文件
                    const fileName = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}.png`;

                    plus.io.requestFileSystem(plus.io.PRIVATE_DOC, (fs) => {
                        fs.root.getFile(fileName, {
                            create: true
                        }, (fileEntry) => {
                            fileEntry.createWriter((writer) => {
                                writer.onwriteend = () => {
                                    // 转换为uni.uploadFile可识别的路径
                                    const localFilePath = plus.io
                                        .convertLocalFileSystemURL(fileEntry.fullPath);
                                    resolve(localFilePath);
                                };
                                writer.onerror = (error) => {
                                    reject(new Error('写入文件失败: ' + error.message));
                                };

                                // 将base64转换为ArrayBuffer,再转为二进制字符串
                                const arrayBuffer = uni.base64ToArrayBuffer(base64Data);
                                const binaryString = String.fromCharCode.apply(null,
                                    new Uint8Array(arrayBuffer));
                                writer.writeBinary(binaryString);
                            }, reject);
                        }, reject);
                    }, reject);
                    // #endif

                    // #ifdef H5
                    // H5端处理:创建Blob URL
                    try {
                        const arrayBuffer = uni.base64ToArrayBuffer(base64Data);
                        const blob = new Blob([arrayBuffer], {
                            type: 'image/png'
                        });
                        const blobUrl = URL.createObjectURL(blob);
                        resolve(blobUrl);
                    } catch (error) {
                        reject(error);
                    }
                    // #endif

                    // #ifdef MP-WEIXIN
                    // 微信小程序端处理
                    const fs = wx.getFileSystemManager();
                    const filePath = `${wx.env.USER_DATA_PATH}/${Date.now()}.png`;
                    try {
                        fs.writeFileSync(filePath, base64Data, 'base64');
                        resolve(filePath);
                    } catch (error) {
                        reject(error);
                    }
                    // #endif
                });
            },
        }
    }
</script>

<style lang="scss" scoped>
    /* 关键:阻止整个页面滚动 */
    page {
        overflow: hidden;
        height: 100%;
        width: 100%;
    }

    .container {
        display: flex;
        flex-direction: column;
        height: 100vh;
        background-color: #f5f5f5;
        overflow: hidden;
        touch-action: none;
        /* 关键 CSS 属性:禁止浏览器处理该区域的触摸动作 */
    }

    .header {
        padding: 30rpx;
        background-color: #fff;
        text-align: center;
        border-bottom: 1rpx solid #eee;

        .title {
            font-size: 36rpx;
            font-weight: bold;
            color: #333;
        }
    }

    .canvas-box {
        flex: 1;
        padding: 30rpx;

        .signature-canvas {
            width: 100%;
            height: 100%;
            background-color: #fff;
            border-radius: 16rpx;
            box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
        }
    }

    .footer {
        display: flex;
        justify-content: space-between;
        padding: 30rpx;
        background-color: #fff;
        border-top: 1rpx solid #eee;

        .btn {
            flex: 1;
            margin: 0 20rpx;
            height: 88rpx;
            line-height: 88rpx;
            border-radius: 44rpx;
            font-size: 32rpx;
            border: none;
        }

        .clear-btn {
            background-color: #f0f0f0;
            color: #666;
        }

        .save-btn {
            background-color: #007aff;
            color: #fff;
        }
    }
</style>