You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

PHP Symfony框架下客户拜访多签名采集存储的技术咨询

我之前在Symfony项目里做过几乎一模一样的客户拜访签名记录功能,分享下实际落地的方案,帮你搞定多签名采集和存储的问题:

一、前端签名采集实现

核心是用Canvas做签名画板,不用依赖第三方外链库,轻量还能适配移动端:

  • 基于Canvas实现手写轨迹:监听鼠标/触摸事件,绘制用户签名轨迹,完成后通过toDataURL('image/png')把签名转成base64字符串
  • 表单里加一个隐藏字段,把base64签名值存进去,提交拜访记录时一起传给后端
二、Symfony实体设计(适配多签名场景)

因为同一客户会有多条拜访记录,每条记录对应一个签名,实体关系要这样设计:

  • Customer实体:存储客户基础信息(名称、联系方式等)
  • Visit实体:通过ManyToOne关联Customer,包含拜访时间、拜访内容等字段,同时用text类型字段存储签名(或者存文件路径,后面说优化方案)

示例Visit实体代码:

// src/Entity/Visit.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use App\Entity\Customer;

#[ORM\Entity]
class Visit
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private $id;

    #[ORM\ManyToOne(targetEntity: Customer::class, inversedBy: 'visits')]
    #[ORM\JoinColumn(nullable: false)]
    private $customer;

    #[ORM\Column(type: 'datetime_immutable')]
    private $visitAt;

    #[ORM\Column(type: 'text', nullable: true)]
    private $notes;

    // 这里先存base64,后面可以优化成文件路径
    #[ORM\Column(type: 'text', nullable: false)]
    private $signature;

    // 自动生成的getter和setter...
}
三、Symfony表单集成

创建VisitType表单,把签名字段设为HiddenType,方便前端赋值:

// src/Form/VisitType.php
namespace App\Form;

use App\Entity\Visit;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;

class VisitType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('customer', EntityType::class, [
                'class' => Customer::class,
                'choice_label' => 'name', // 显示客户名称
            ])
            ->add('visitAt', DateTimeType::class, [
                'widget' => 'single_text',
                'data' => new \DateTimeImmutable(), // 默认当前时间
            ])
            ->add('notes', TextareaType::class)
            ->add('signature', HiddenType::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Visit::class,
        ]);
    }
}
四、签名存储优化(从base64转文件)

直接存base64会占用大量数据库空间,建议转成图片文件存到服务器,数据库只存文件路径:
在控制器里处理表单提交时,把base64转成png文件:

// src/Controller/VisitController.php
namespace App\Controller;

use App\Entity\Visit;
use App\Form\VisitType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class VisitController extends AbstractController
{
    public function new(Request $request, EntityManagerInterface $em): Response
    {
        $visit = new Visit();
        $form = $this->createForm(VisitType::class, $visit);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $signatureBase64 = $form->get('signature')->getData();
            if ($signatureBase64) {
                // 去掉base64前缀,解码成二进制内容
                $signatureContent = base64_decode(str_replace('data:image/png;base64,', '', $signatureBase64));
                // 生成唯一文件名
                $fileName = uniqid('signature_', true) . '.png';
                // 存到public/uploads/signatures目录(要提前创建并设置权限)
                $savePath = $this->getParameter('kernel.project_dir') . '/public/uploads/signatures/' . $fileName;
                file_put_contents($savePath, $signatureContent);
                
                // 存储相对路径,方便前端访问
                $visit->setSignature('/uploads/signatures/' . $fileName);
            }

            $em->persist($visit);
            $em->flush();

            return $this->redirectToRoute('visit_index');
        }

        return $this->render('visit/new.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}
五、前端模板整合签名画板

在Twig模板里把Canvas画板和表单绑定,实现实时签名和清除功能:

{# templates/visit/new.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}
    <h1>记录客户拜访</h1>

    {{ form_start(form) }}
        {{ form_row(form.customer) }}
        {{ form_row(form.visitAt) }}
        {{ form_row(form.notes) }}

        <div class="signature-section">
            <h3>请客户在此签名</h3>
            <canvas id="signatureCanvas" width="400" height="200" style="border:1px solid #ddd;"></canvas>
            <div class="mt-2">
                <button type="button" id="clearBtn" class="btn btn-secondary">清除签名</button>
            </div>
            {{ form_widget(form.signature) }}
        </div>

        <button type="submit" class="btn btn-primary mt-4">保存拜访记录</button>
    {{ form_end(form) }}

    <script>
        const canvas = document.getElementById('signatureCanvas');
        const ctx = canvas.getContext('2d');
        const signatureInput = document.getElementById('{{ form.signature.vars.id }}');
        let isDrawing = false;

        // 鼠标事件处理
        canvas.addEventListener('mousedown', startDraw);
        canvas.addEventListener('mousemove', draw);
        canvas.addEventListener('mouseup', stopDraw);
        canvas.addEventListener('mouseout', stopDraw);

        // 触摸事件适配移动端
        canvas.addEventListener('touchstart', (e) => {
            e.preventDefault();
            const touch = e.touches[0];
            const mouseEvent = new MouseEvent('mousedown', {
                clientX: touch.clientX,
                clientY: touch.clientY
            });
            canvas.dispatchEvent(mouseEvent);
        });

        function startDraw(e) {
            isDrawing = true;
            ctx.beginPath();
            ctx.moveTo(e.offsetX, e.offsetY);
            ctx.lineWidth = 2;
            ctx.strokeStyle = '#000';
        }

        function draw(e) {
            if (!isDrawing) return;
            ctx.lineTo(e.offsetX, e.offsetY);
            ctx.stroke();
        }

        function stopDraw() {
            isDrawing = false;
            // 把签名转成base64存到隐藏字段
            signatureInput.value = canvas.toDataURL('image/png');
        }

        document.getElementById('clearBtn').addEventListener('click', () => {
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            signatureInput.value = '';
        });
    </script>
{% endblock %}

内容的提问来源于stack exchange,提问作者Mgulizia

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.20 12:13:37