手写签名组件实现原理

📋 目录

  1. 概述
  2. 核心技术
  3. 实现流程
  4. 关键技术点
  5. 代码结构
  6. 详细实现
  7. 功能特性

概述

手写签名组件是一个基于 HTML5 Canvas 的电子签名工具,支持鼠标和触摸屏设备,提供完整的签名绘制、撤销、清除和保存功能。

主要功能

  • ✏️ 支持手写绘制(鼠标/触摸屏)
  • 🎨 8种预设颜色可选
  • 📏 可调节线条粗细(1-10px)
  • ↩️ 支持多步撤销操作
  • 🗑️ 一键清除画布
  • 💾 保存为 PNG 图片
  • 📱 响应式设计,适配各种屏幕

📸 效果图

初始状态

组件初始加载时的界面展示,包括:

  • 顶部标题
  • 工具栏(颜色选择、粗细调节、操作按钮)
  • 空白画布
  • 功能特性说明
  • 使用说明

签名效果

在画布上绘制签名 "Hello" 后的效果展示,可以看到:

  • 平滑的线条
  • 清晰的签名
  • 完整的工具栏功能

核心技术

1. HTML5 Canvas API

Canvas 是 HTML5 提供的一个用于图形绘制的元素,通过 JavaScript 可以在 Canvas 上绘制 2D 图形。

关键 API

  • getContext('2d') - 获取 2D 绘图上下文
  • beginPath() - 开始一条新路径
  • moveTo(x, y) - 移动画笔到指定位置
  • lineTo(x, y) - 从当前位置画线到指定位置
  • stroke() - 执行绘制,将路径渲染到画布
  • clearRect(x, y, width, height) - 清除指定矩形区域
  • toDataURL() - 将画布内容导出为图片数据

2. 响应式数据

使用 Vue 3 的 refreactive 管理组件状态:

javascript 复制代码
const ctx = ref(null)              // Canvas 绘图上下文
const isDrawing = ref(false)         // 是否正在绘制
const selectedColor = ref('#000000')  // 选中的颜色
const lineWidth = ref(3)             // 线条粗细
const history = ref([])              // 历史记录(撤销用)
const currentPath = ref([])          // 当前绘制的路径

3. 事件监听

组件监听多种事件以支持不同设备:

事件类型 事件名称 用途
鼠标按下 mousedown 开始绘制
鼠标移动 mousemove 绘制线条
鼠标抬起 mouseup 停止绘制
鼠标离开 mouseleave 停止绘制
触摸开始 touchstart 开始绘制(移动端)
触摸移动 touchmove 绘制线条(移动端)
触摸结束 touchend 停止绘制(移动端)

实现流程

1. 组件生命周期

css 复制代码
组件挂载
    ↓
初始化 Canvas
    ↓
获取 2D 上下文
    ↓
调整画布大小
    ↓
监听窗口大小变化

代码实现

javascript 复制代码
onMounted(() => {
  initCanvas()  // 初始化画布
  window.addEventListener('resize', resizeCanvas)  // 监听窗口大小变化
})

onUnmounted(() => {
  window.removeEventListener('resize', resizeCanvas)  // 清理事件监听
})

2. 画布初始化

javascript 复制代码
const initCanvas = () => {
  const canvas = canvasRef.value
  if (!canvas) return
  
  // ⭐ 关键:获取 Canvas 2D 绘图上下文
  ctx.value = canvas.getContext('2d')
  resizeCanvas()
}

说明

  • canvasRef.value - 通过 Vue 的 ref 获取 Canvas DOM 元素
  • getContext('2d') - 获取 2D 绘图上下文,这是所有绘图操作的基础
  • resizeCanvas() - 调整画布大小以适应容器

3. 绘制流程

鼠标绘制流程

arduino 复制代码
用户按下鼠标
    ↓
记录起始点坐标
    ↓
设置绘制状态为 true
    ↓
用户移动鼠标
    ↓
添加新的坐标点到路径
    ↓
绘制从上一个点到当前点的线条
    ↓
用户抬起鼠标
    ↓
停止绘制
    ↓
将当前路径保存到历史记录

触摸屏绘制流程

markdown 复制代码
用户触摸屏幕
    ↓
阻止默认滚动行为
    ↓
获取第一个触摸点
    ↓
转换为模拟鼠标事件
    ↓
执行开始绘制逻辑
    ↓
用户移动手指
    ↓
阻止默认滚动行为
    ↓
获取触摸点
    ↓
转换为模拟鼠标事件
    ↓
执行绘制逻辑
    ↓
用户抬起手指
    ↓
停止绘制

触摸事件处理

javascript 复制代码
const handleTouchStart = (e) => {
  e.preventDefault()  // 阻止默认滚动行为
  const touch = e.touches[0]
  const mouseEvent = {
    clientX: touch.clientX,
    clientY: touch.clientY
  }
  startDrawing(mouseEvent)  // 转换为鼠标事件处理
}

关键技术点

1. 坐标计算

将鼠标/触摸事件的屏幕坐标转换为 Canvas 内部坐标:

javascript 复制代码
const addPoint = (e) => {
  const canvas = canvasRef.value
  const rect = canvas.getBoundingClientRect()  // 获取 Canvas 在视口中的位置
  const x = e.clientX - rect.left           // 计算 Canvas 内部的 x 坐标
  const y = e.clientY - rect.top            // 计算 Canvas 内部的 y 坐标
  
  currentPath.value.push({ x, y })  // 保存坐标点
}

原理

  • e.clientX/Y - 鼠标/触摸点相对于视口的坐标
  • rect.left/top - Canvas 元素相对于视口的位置
  • 相减得到相对于 Canvas 左上角的坐标

2. 路径绘制

绘制平滑的线条需要设置正确的 Canvas 属性:

javascript 复制代码
const drawPath = () => {
  if (currentPath.value.length < 2) return  // 至少需要2个点才能画线
  
  const context = ctx.value
  const path = currentPath.value
  
  context.beginPath()                    // 开始新路径
  context.strokeStyle = selectedColor.value  // 设置颜色
  context.lineWidth = lineWidth.value        // 设置粗细
  context.lineCap = 'round'             // 圆形端点(平滑)
  context.lineJoin = 'round'            // 圆形连接(平滑)
  
  context.moveTo(path[0].x, path[0].y)  // 移动到起点
  
  for (let i = 1; i < path.length; i++) {
    context.lineTo(path[i].x, path[i].y)  // 连续画线
  }
  
  context.stroke()  // 执行绘制
}

平滑处理

  • lineCap: 'round' - 线条末端为圆形,避免锯齿
  • lineJoin: 'round' - 线条连接处为圆形,避免尖角

3. 历史记录与撤销

每次完成一次绘制(鼠标抬起)时,将路径保存到历史记录:

javascript 复制代码
const stopDrawing = () => {
  if (!isDrawing.value) return
  
  isDrawing.value = false
  if (currentPath.value.length > 0) {
    history.value.push([...currentPath.value])  // 保存路径副本
  }
  currentPath.value = []  // 清空当前路径
}

撤销实现

javascript 复制代码
const undo = () => {
  if (history.value.length === 0) return
  
  history.value.pop()  // 移除最后一条路径
  redrawHistory()  // 重绘所有历史路径
}

重绘历史记录

javascript 复制代码
const redrawHistory = () => {
  const canvas = canvasRef.value
  const context = ctx.value
  
  context.clearRect(0, 0, canvas.width, canvas.height)  // 清空画布
  
  history.value.forEach(path => {
    // 重绘每条路径
    context.beginPath()
    context.strokeStyle = path.color || selectedColor.value
    context.lineWidth = path.width || lineWidth.value
    context.lineCap = 'round'
    context.lineJoin = 'round'
    
    context.moveTo(path[0].x, path[0].y)
    for (let i = 1; i < path.length; i++) {
      context.lineTo(path[i].x, path[i].y)
    }
    context.stroke()
  })
}

4. 保存为图片

将 Canvas 内容导出为 PNG 图片:

javascript 复制代码
const saveSignature = () => {
  const canvas = canvasRef.value
  
  if (history.value.length === 0) {
    alert('请先绘制签名!')
    return
  }
  
  // 创建临时 Canvas 添加白色背景
  const tempCanvas = document.createElement('canvas')
  const tempCtx = tempCanvas.getContext('2d')
  tempCanvas.width = canvas.width
  tempCanvas.height = canvas.height
  
  // 填充白色背景
  tempCtx.fillStyle = '#ffffff'
  tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height)
  
  // 绘制签名到临时 Canvas
  tempCtx.drawImage(canvas, 0, 0)
  
  // 导出为 PNG
  const dataURL = tempCanvas.toDataURL('image/png')
  
  // 创建下载链接
  const link = document.createElement('a')
  link.download = `signature_${Date.now()}.png`
  link.href = dataURL
  link.click()  // 触发下载
}

为什么需要临时 Canvas?

  • 原始 Canvas 背景是透明的
  • PNG 透明背景在某些场景下显示效果不好
  • 临时 Canvas 先填充白色背景,再绘制签名

代码结构

组件结构

scss 复制代码
SignaturePad.vue
├── template (模板)
│   ├── 标题
│   ├── 工具栏
│   │   ├── 颜色选择器
│   │   ├── 粗细调节滑块
│   │   └── 操作按钮(清除/撤销/保存)
│   ├── 画布容器
│   │   └── Canvas 元素
│   └── 提示信息
├── script (脚本)
│   ├── 响应式变量定义
│   ├── 生命周期钩子
│   ├── 初始化函数
│   ├── 绘制相关函数
│   ├── 事件处理函数
│   └── 工具函数
└── style (样式)
    ├── 容器样式
    ├── 工具栏样式
    ├── 画布样式
    └── 按钮样式

数据流

bash 复制代码
用户操作
    ↓
触发事件(mousedown/touchstart)
    ↓
更新状态(isDrawing = true)
    ↓
记录坐标点(currentPath)
    ↓
绘制线条(drawPath)
    ↓
停止绘制(mouseup/touchend)
    ↓
保存历史(history.push)
    ↓
更新 UI(撤销按钮状态)

详细实现

1. 颜色选择

javascript 复制代码
const colors = [
  '#000000',  // 黑色
  '#ef4444',  // 红色
  '#f59e0b',  // 橙色
  '#10b981',  // 绿色
  '#3b82f6',  // 蓝色
  '#6366f1',  // 靛色
  '#8b5cf6',  // 紫色
  '#14b8a6'   // 青色
]

// 用户点击颜色选项
const selectedColor = ref('#000000')

2. 线条粗细

javascript 复制代码
const lineWidth = ref(3)  // 默认 3px

// 滑块范围:1-10px
<input 
  type="range" 
  v-model="lineWidth" 
  min="1" 
  max="10" 
/>

3. 响应式画布

javascript 复制代码
const resizeCanvas = () => {
  const canvas = canvasRef.value
  if (!canvas) return
  
  const wrapper = canvas.parentElement
  canvas.width = wrapper.offsetWidth   // 设置画布宽度
  canvas.height = wrapper.offsetHeight // 设置画布高度
  
  redrawHistory()  // 调整大小后重绘历史
}

窗口大小变化时

  1. 获取父容器尺寸
  2. 更新 Canvas 尺寸
  3. 重绘所有历史路径(因为调整 Canvas 大小会清空内容)

功能特性

1. 多设备支持

设备类型 支持方式
PC 端 鼠标事件(mousedown/move/up)
移动端 触摸事件(touchstart/move/end)
平板 同时支持鼠标和触摸

2. 撤销功能

  • 多步撤销:可以连续撤销多次操作
  • 状态管理:使用数组存储所有路径
  • 实时更新:撤销按钮根据历史记录启用/禁用

3. 保存功能

  • 自动命名signature_时间戳.png
  • 白色背景:确保签名在所有背景下清晰可见
  • PNG 格式:支持透明度和高质量

4. 性能优化

  • 按需绘制:只在鼠标移动时绘制,避免不必要的渲染
  • 路径缓存:使用数组存储路径,减少 Canvas 操作
  • 事件节流:浏览器自动优化高频事件

总结

手写签名组件的核心实现原理:

  1. Canvas 2D 绘图:利用 HTML5 Canvas API 实现流畅的线条绘制
  2. 事件监听:同时支持鼠标和触摸事件,适配多种设备
  3. 坐标转换:将屏幕坐标转换为 Canvas 内部坐标
  4. 路径管理:使用数组存储路径点,实现撤销功能
  5. 平滑处理 :通过 lineCaplineJoin 实现平滑线条
  6. 图片导出:使用临时 Canvas 添加背景并导出 PNG

这种实现方式简单高效,兼容性好,适合各种应用场景。


完整源码

SignaturePad.vue

vue 复制代码
<template>
  <!-- 签名容器 -->
  <div class="signature-container">
    <!-- 标题 -->
    <h2 class="signature-title">手写签名</h2>
    
    <!-- 工具栏 -->
    <div class="toolbar">
      <!-- 颜色选择 -->
      <div class="tool-group">
        <label>颜色:</label>
        <div class="color-picker">
          <div 
            v-for="color in colors" 
            :key="color"
            class="color-option"
            :class="{ active: selectedColor === color }"
            :style="{ backgroundColor: color }"
            @click="selectedColor = color"
          ></div>
        </div>
      </div>
      
      <!-- 粗细调节 -->
      <div class="tool-group">
        <label>粗细:</label>
        <input 
          type="range" 
          v-model="lineWidth" 
          min="1" 
          max="10" 
          class="width-slider"
        />
        <span>{{ lineWidth }}px</span>
      </div>
      
      <!-- 操作按钮 -->
      <div class="tool-group buttons">
        <button class="btn btn-clear" @click="clearCanvas">清除</button>
        <button class="btn btn-undo" @click="undo" :disabled="history.length === 0">撤销</button>
        <button class="btn btn-save" @click="saveSignature">保存</button>
      </div>
    </div>
    
    <!-- 画布区域 -->
    <div class="canvas-wrapper">
      <canvas 
        ref="canvasRef"
        class="signature-canvas"
        @mousedown="startDrawing"
        @mousemove="draw"
        @mouseup="stopDrawing"
        @mouseleave="stopDrawing"
        @touchstart="handleTouchStart"
        @touchmove="handleTouchMove"
        @touchend="handleTouchEnd"
      ></canvas>
    </div>
    
    <!-- 提示信息 -->
    <div class="tips">
      <p>💡 在画布上绘制签名,支持鼠标和触摸屏</p>
      <p>💡 点击"保存"按钮下载签名图片</p>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

/* 画布引用 */
const canvasRef = ref(null)

/* 绘图上下文 */
const ctx = ref(null)

/* 是否正在绘制 */
const isDrawing = ref(false)

/* 选中的颜色 */
const selectedColor = ref('#000000')

/* 线条粗细 */
const lineWidth = ref(3)

/* 历史记录(用于撤销) */
const history = ref([])

/* 当前路径 */
const currentPath = ref([])

/* 颜色选项 */
const colors = [
  '#000000',
  '#ef4444',
  '#f59e0b',
  '#10b981',
  '#3b82f6',
  '#6366f1',
  '#8b5cf6',
  '#ec4899',
  '#14b8a6'
]

/* 组件挂载 */
onMounted(() => {
  initCanvas()
  window.addEventListener('resize', resizeCanvas)
})

/* 组件卸载 */
onUnmounted(() => {
  window.removeEventListener('resize', resizeCanvas)
})

/* 初始化画布 */
const initCanvas = () => {
  const canvas = canvasRef.value
  if (!canvas) return
  
  ctx.value = canvas.getContext('2d')
  resizeCanvas()
}

/* 调整画布大小 */
const resizeCanvas = () => {
  const canvas = canvasRef.value
  if (!canvas) return
  
  const wrapper = canvas.parentElement
  canvas.width = wrapper.offsetWidth
  canvas.height = wrapper.offsetHeight
  
  /* 重绘历史记录 */
  redrawHistory()
}

/* 开始绘制 */
const startDrawing = (e) => {
  isDrawing.value = true
  currentPath.value = []
  addPoint(e)
}

/* 绘制 */
const draw = (e) => {
  if (!isDrawing.value) return
  
  addPoint(e)
  drawPath()
}

/* 停止绘制 */
const stopDrawing = () => {
  if (!isDrawing.value) return
  
  isDrawing.value = false
  if (currentPath.value.length > 0) {
    history.value.push([...currentPath.value])
  }
  currentPath.value = []
}

/* 添加点 */
const addPoint = (e) => {
  const canvas = canvasRef.value
  const rect = canvas.getBoundingClientRect()
  const x = e.clientX - rect.left
  const y = e.clientY - rect.top
  
  currentPath.value.push({ x, y })
}

/* 绘制路径 */
const drawPath = () => {
  /* 如果当前路径的点数少于2个,无法绘制线条,直接返回 */
  if (currentPath.value.length < 2) return
  
  /* 获取Canvas 2D绘图上下文 */
  const context = ctx.value
  /* 获取当前正在绘制的路径点数组 */
  const path = currentPath.value
  
  /* 开始一条新的路径 */
  context.beginPath()
  /* 设置线条颜色为当前选中的颜色 */
  context.strokeStyle = selectedColor.value
  /* 设置线条宽度为当前选中的粗细 */
  context.lineWidth = lineWidth.value
  /* 设置线条端点样式为圆形,使线条末端平滑 */
  context.lineCap = 'round'
  /* 设置线条连接处样式为圆形,使线条转折处平滑 */
  context.lineJoin = 'round'
  
  /* 将画笔移动到路径的第一个点(起点) */
  context.moveTo(path[0].x, path[0].y)
  
  /* 遍历路径中的所有点(从第二个点开始) */
  for (let i = 1; i < path.length; i++) {
    /* 从上一个点画线到当前点 */
    context.lineTo(path[i].x, path[i].y)
  }
  
  /* 执行绘制,将路径绘制到画布上 */
  context.stroke()
}

/* 触摸开始 */
const handleTouchStart = (e) => {
  e.preventDefault()
  const touch = e.touches[0]
  const mouseEvent = {
    clientX: touch.clientX,
    clientY: touch.clientY
  }
  startDrawing(mouseEvent)
}

/* 触摸移动 */
const handleTouchMove = (e) => {
  e.preventDefault()
  const touch = e.touches[0]
  const mouseEvent = {
    clientX: touch.clientX,
    clientY: touch.clientY
  }
  draw(mouseEvent)
}

/* 触摸结束 */
const handleTouchEnd = (e) => {
  e.preventDefault()
  stopDrawing()
}

/* 清除画布 */
const clearCanvas = () => {
  const canvas = canvasRef.value
  const context = ctx.value
  
  context.clearRect(0, 0, canvas.width, canvas.height)
  history.value = []
  currentPath.value = []
}

/* 撤销 */
const undo = () => {
  if (history.value.length === 0) return
  
  history.value.pop()
  redrawHistory()
}

/* 重绘历史记录 */
const redrawHistory = () => {
  const canvas = canvasRef.value
  const context = ctx.value
  
  context.clearRect(0, 0, canvas.width, canvas.height)
  
  history.value.forEach(path => {
    if (path.length < 2) return
    
    context.beginPath()
    context.strokeStyle = path.color || selectedColor.value
    context.lineWidth = path.width || lineWidth.value
    context.lineCap = 'round'
    context.lineJoin = 'round'
    
    context.moveTo(path[0].x, path[0].y)
    
    for (let i = 1; i < path.length; i++) {
      context.lineTo(path[i].x, path[i].y)
    }
    
    context.stroke()
  })
}

/* 保存签名 */
const saveSignature = () => {
  const canvas = canvasRef.value
  
  if (history.value.length === 0) {
    alert('请先绘制签名!')
    return
  }
  
  /* 创建白色背景 */
  const tempCanvas = document.createElement('canvas')
  const tempCtx = tempCanvas.getContext('2d')
  tempCanvas.width = canvas.width
  tempCanvas.height = canvas.height
  
  /* 填充白色背景 */
  tempCtx.fillStyle = '#ffffff'
  tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height)
  
  /* 绘制签名 */
  tempCtx.drawImage(canvas, 0, 0)
  
  /* 导出图片 */
  const dataURL = tempCanvas.toDataURL('image/png')
  
  /* 创建下载链接 */
  const link = document.createElement('a')
  link.download = `signature_${Date.now()}.png`
  link.href = dataURL
  link.click()
}
</script>

<style scoped>
/* 签名容器 */
.signature-container {
  /* 内边距 */
  padding: 40px 20px;
  /* 最大宽度 */
  max-width: 1000px;
  /* 水平居中 */
  margin: 0 auto;
}

/* 签名标题 */
.signature-title {
  /* 字体大小 */
  font-size: 32px;
  /* 字重 */
  font-weight: bold;
  /* 文字颜色 */
  color: #1e293b;
  /* 边距 */
  margin-bottom: 30px;
  /* 文字对齐 */
  text-align: center;
}

/* 工具栏 */
.toolbar {
  /* 背景 */
  background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
  /* 边框 */
  border: 2px solid #cbd5e1;
  /* 边框半径 */
  border-radius: 12px;
  /* 内边距 */
  padding: 24px;
  /* 边距 */
  margin-bottom: 30px;
  /* 弹性布局 */
  display: flex;
  /* 间距 */
  gap: 24px;
  /* 换行 */
  flex-wrap: wrap;
  /* 对齐 */
  align-items: center;
}

/* 工具组 */
.tool-group {
  /* 弹性布局 */
  display: flex;
  /* 垂直居中 */
  align-items: center;
  /* 间距 */
  gap: 12px;
  /* 字体大小 */
  font-size: 14px;
  /* 文字颜色 */
  color: #475569;
}

/* 颜色选择器 */
.color-picker {
  /* 弹性布局 */
  display: flex;
  /* 间距 */
  gap: 8px;
}

/* 颜色选项 */
.color-option {
  /* 宽度 */
  width: 32px;
  /* 高度 */
  height: 32px;
  /* 边框半径 */
  border-radius: 50%;
  /* 边框 */
  border: 2px solid transparent;
  /* 光标 */
  cursor: pointer;
  /* 过渡 */
  transition: all 0.2s ease;
  /* 之后 */
  &:hover {
    /* 变换 */
    transform: scale(1.1);
  }
  /* 选中 */
  &.active {
    /* 边框 */
    border-color: #6366f1;
    /* 盒阴影 */
    box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.5);
  }
}

/* 粗细滑块 */
.width-slider {
  /* 宽度 */
  width: 150px;
  /* 高度 */
  height: 6px;
  /* 背景 */
  background: #cbd5e1;
  /* 边框半径 */
  border-radius: 3px;
  /* 外观 */
  appearance: none;
  /* 之后 */
  &::-webkit-slider-thumb {
    /* 外观 */
    appearance: none;
    /* 宽度 */
    width: 18px;
    /* 高度 */
    height: 18px;
    /* 背景 */
    background: #6366f1;
    /* 边框半径 */
    border-radius: 50%;
    /* 光标 */
    cursor: pointer;
    /* 之后 */
    &:hover {
      /* 背景 */
      background: #4f46e5;
    }
  }
}

/* 按钮组 */
.buttons {
  /* 弹性布局 */
  display: flex;
  /* 间距 */
  gap: 12px;
}

/* 按钮 */
.btn {
  /* 内边距 */
  padding: 10px 24px;
  /* 字体大小 */
  font-size: 14px;
  /* 字重 */
  font-weight: bold;
  /* 文字颜色 */
  color: white;
  /* 边框 */
  border: none;
  /* 边框半径 */
  border-radius: 8px;
  /* 光标 */
  cursor: pointer;
  /* 过渡 */
  transition: all 0.3s ease;
  /* 之后 */
  &:hover {
    /* 变换 */
    transform: translateY(-2px);
    /* 盒阴影 */
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
  }
  /* 禁用 */
  &:disabled {
    /* 不透明度 */
    opacity: 0.5;
    /* 光标 */
    cursor: not-allowed;
    /* 之后 */
    &:hover {
      /* 变换 */
      transform: none;
      /* 盒阴影 */
      box-shadow: none;
    }
  }
}

/* 清除按钮 */
.btn-clear {
  /* 背景 */
  background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
}

/* 撤销按钮 */
.btn-undo {
  /* 背景 */
  background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}

/* 保存按钮 */
.btn-save {
  /* 背景 */
  background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}

/* 画布包装器 */
.canvas-wrapper {
  /* 背景 */
  background: #ffffff;
  /* 边框 */
  border: 3px solid #e2e8f0;
  /* 边框半径 */
  border-radius: 16px;
  /* 盒阴影 */
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
  /* 溢出隐藏 */
  overflow: hidden;
}

/* 签名画布 */
.signature-canvas {
  /* 显示 */
  display: block;
  /* 光标 */
  cursor: crosshair;
  /* 触摸操作 */
  touch-action: none;
}

/* 提示信息 */
.tips {
  /* 背景 */
  background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
  /* 边框 */
  border: 2px dashed #f59e0b;
  /* 边框半径 */
  border-radius: 12px;
  /* 内边距 */
  padding: 20px;
  /* 边距 */
  margin-top: 30px;
}

/* 提示段落 */
.tips p {
  /* 字体大小 */
  font-size: 14px;
  /* 行高 */
  line-height: 1.8;
  /* 文字颜色 */
  color: #78350f;
  /* 边距 */
  margin: 8px 0;
}
</style>

SignatureDemo.vue

vue 复制代码
<template>
  <!-- 签名演示容器 -->
  <div class="signature-demo-container">
    <!-- 标题 -->
    <h1 class="demo-title">手写签名</h1>
    <p class="demo-subtitle">支持鼠标和触摸屏的电子签名组件</p>
    
    <!-- 签名组件 -->
    <SignaturePad />
    
    <!-- 功能说明 -->
    <div class="features">
      <h3>✨ 功能特性</h3>
      <ul class="feature-list">
        <li>🎨 8种预设颜色可选</li>
        <li>✏️ 可调节线条粗细(1-10px)</li>
        <li>↩️ 支持撤销操作</li>
        <li>🗑️ 一键清除画布</li>
        <li>💾 保存为PNG图片</li>
        <li>📱 支持触摸屏设备</li>
        <li>🖱️ 支持鼠标操作</li>
      </ul>
    </div>
    
    <!-- 使用说明 -->
    <div class="usage">
      <h3>📖 使用说明</h3>
      <ol class="usage-list">
        <li>选择喜欢的颜色</li>
        <li>调整线条粗细</li>
        <li>在画布上绘制签名</li>
        <li>不满意可以点击"撤销"</li>
        <li>需要重新开始点击"清除"</li>
        <li>完成后点击"保存"下载图片</li>
      </ol>
    </div>
  </div>
</template>

<script setup>
import SignaturePad from './SignaturePad.vue'
</script>

<style scoped>
/* 演示容器 */
.signature-demo-container {
  /* 内边距 */
  padding: 40px 20px;
  /* 最大宽度 */
  max-width: 1200px;
  /* 水平居中 */
  margin: 0 auto;
}

/* 演示标题 */
.demo-title {
  /* 字体大小 */
  font-size: 36px;
  /* 字重 */
  font-weight: bold;
  /* 文字颜色 */
  color: #1e293b;
  /* 边距 */
  margin-bottom: 10px;
  /* 文字对齐 */
  text-align: center;
}

/* 副标题 */
.demo-subtitle {
  /* 字体大小 */
  font-size: 16px;
  /* 文字颜色 */
  color: #64748b;
  /* 边距 */
  margin-bottom: 40px;
  /* 文字对齐 */
  text-align: center;
}

/* 功能区域 */
.features {
  /* 背景 */
  background: linear-gradient(135deg, #f0f9ff 0%, #e0e7ff 100%);
  /* 边框 */
  border: 2px solid #6366f1;
  /* 边框半径 */
  border-radius: 12px;
  /* 内边距 */
  padding: 24px;
  /* 边距 */
  margin-bottom: 30px;
}

/* 功能标题 */
.features h3 {
  /* 字体大小 */
  font-size: 20px;
  /* 字重 */
  font-weight: bold;
  /* 文字颜色 */
  color: #4f46e5;
  /* 边距 */
  margin-bottom: 16px;
}

/* 功能列表 */
.feature-list {
  /* 列表样式 */
  list-style: none;
  /* 内边距 */
  padding: 0;
  /* 边距 */
  margin: 0;
  /* 网格布局 */
  display: grid;
  /* 列数 */
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  /* 间距 */
  gap: 12px;
}

/* 功能项 */
.feature-list li {
  /* 背景 */
  background: white;
  /* 边框 */
  border: 1px solid #e2e8f0;
  /* 边框半径 */
  border-radius: 8px;
  /* 内边距 */
  padding: 12px 16px;
  /* 字体大小 */
  font-size: 15px;
  /* 行高 */
  line-height: 1.6;
  /* 文字颜色 */
  color: #334155;
  /* 盒阴影 */
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
  /* 过渡 */
  transition: all 0.3s ease;
  /* 之后 */
  &:hover {
    /* 变换 */
    transform: translateY(-2px);
    /* 盒阴影 */
    box-shadow: 0 4px 8px rgba(99, 102, 241, 0.15);
  }
}

/* 使用说明区域 */
.usage {
  /* 背景 */
  background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
  /* 边框 */
  border: 2px dashed #f59e0b;
  /* 边框半径 */
  border-radius: 12px;
  /* 内边距 */
  padding: 24px;
}

/* 使用说明标题 */
.usage h3 {
  /* 字体大小 */
  font-size: 20px;
  /* 字重 */
  font-weight: bold;
  /* 文字颜色 */
  color: #92400e;
  /* 边距 */
  margin-bottom: 16px;
}

/* 使用说明列表 */
.usage-list {
  /* 列表样式 */
  list-style: none;
  /* 计数器 */
  counter-reset: usage-counter;
  /* 内边距 */
  padding: 0;
  /* 边距 */
  margin: 0;
}

/* 使用说明项 */
.usage-list li {
  /* 字体大小 */
  font-size: 15px;
  /* 行高 */
  line-height: 1.8;
  /* 文字颜色 */
  color: #78350f;
  /* 边距 */
  margin-bottom: 10px;
  /* 相对定位 */
  position: relative;
  /* 左边距 */
  padding-left: 36px;
  /* 之后 */
  &::before {
    /* 内容 */
    content: counter(usage-counter);
    /* 计数器 */
    counter-increment: usage-counter;
    /* 绝对定位 */
    position: absolute;
    /* 左边距 */
    left: 0;
    /* 顶部 */
    top: 0;
    /* 宽度 */
    width: 28px;
    /* 高度 */
    height: 28px;
    /* 弹性布局 */
    display: flex;
    /* 水平居中 */
    justify-content: center;
    /* 垂直居中 */
    align-items: center;
    /* 字体大小 */
    font-size: 14px;
    /* 字重 */
    font-weight: bold;
    /* 文字颜色 */
    color: white;
    /* 背景 */
    background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
    /* 边框半径 */
    border-radius: 50%;
  }
}
</style>
相关推荐
mldong5 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排6 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60616 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角7 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!7 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰8 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi9 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen10 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马11 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒11 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端