手写签名组件实现原理

📋 目录

  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>
相关推荐
Lufeidata2 小时前
go语言学习记录-入门阶段
前端·学习·golang
英俊潇洒美少年2 小时前
前端 跨域解决方案
前端
程序员小李白3 小时前
vue题目
前端·javascript·vue.js
okra-3 小时前
什么是接口?
服务器·前端·网络
humors2213 小时前
Deepseek工具:H5+Vue 项目转微信小程序报告生成工具
前端·vue.js·微信小程序·h5·工具·报告
方安乐3 小时前
ESLint代码规范(二)
前端·javascript·代码规范
zzginfo3 小时前
var、let、const、无申明 四种变量在赋值前,使用的情况
开发语言·前端·javascript
贺小涛3 小时前
Vue介绍
前端·javascript·vue.js
cch89183 小时前
React Hooks的支持
前端·javascript·react.js