本文目录导读:

在Symfony项目中,实现表单的草稿恢复功能是一个常见需求,我来为你提供一个完整的解决方案。
数据库设计
// src/Entity/Draft.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity(repositoryClass: DraftRepository::class)]
class Draft
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['draft:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['draft:read'])]
private ?string $formType = null;
#[ORM\Column(type: 'text')]
private ?string $data = null;
#[ORM\Column(type: 'datetime')]
#[Groups(['draft:read'])]
private ?\DateTimeInterface $createdAt = null;
#[ORM\Column(type: 'datetime', nullable: true)]
#[Groups(['draft:read'])]
private ?\DateTimeInterface $updatedAt = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $userId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $sessionId = null;
// Getters and setters...
}
草稿服务
// src/Service/DraftService.php
namespace App\Service;
use App\Entity\Draft;
use App\Form\Type\YourFormType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
class DraftService
{
private EntityManagerInterface $entityManager;
private SerializerInterface $serializer;
private FormFactoryInterface $formFactory;
private RequestStack $requestStack;
private ?Security $security;
public function __construct(
EntityManagerInterface $entityManager,
SerializerInterface $serializer,
FormFactoryInterface $formFactory,
RequestStack $requestStack,
?Security $security = null
) {
$this->entityManager = $entityManager;
$this->serializer = $serializer;
$this->formFactory = $formFactory;
$this->requestStack = $requestStack;
$this->security = $security;
}
public function saveDraft(string $formType, array $data, ?int $draftId = null): Draft
{
$draft = $draftId ?
$this->entityManager->getRepository(Draft::class)->find($draftId) :
new Draft();
if (!$draft) {
throw new \InvalidArgumentException('Draft not found');
}
$draft->setFormType($formType);
$draft->setData($this->serializer->serialize($data, 'json'));
$draft->setUpdatedAt(new \DateTime());
if (!$draftId) {
$draft->setCreatedAt(new \DateTime());
// 绑定用户或会话
if ($this->security && $this->security->getUser()) {
$draft->setUserId($this->security->getUser()->getId());
} else {
$session = $this->requestStack->getSession();
$draft->setSessionId($session->getId());
}
}
$this->entityManager->persist($draft);
$this->entityManager->flush();
return $draft;
}
public function loadDraft(int $draftId): ?array
{
$draft = $this->entityManager->getRepository(Draft::class)->find($draftId);
if (!$draft) {
return null;
}
return $this->serializer->deserialize(
$draft->getData(),
'array',
'json'
);
}
public function getFormDataFromDraft(FormInterface $form, int $draftId): FormInterface
{
$draftData = $this->loadDraft($draftId);
if ($draftData) {
$form->submit($draftData, false); // false = 不验证
}
return $form;
}
public function getUserDrafts(?string $formType = null): array
{
$criteria = [];
if ($this->security && $this->security->getUser()) {
$criteria['userId'] = $this->security->getUser()->getId();
} else {
$session = $this->requestStack->getSession();
$criteria['sessionId'] = $session->getId();
}
if ($formType) {
$criteria['formType'] = $formType;
}
return $this->entityManager
->getRepository(Draft::class)
->findBy($criteria, ['updatedAt' => 'DESC']);
}
public function deleteDraft(int $draftId): void
{
$draft = $this->entityManager->getRepository(Draft::class)->find($draftId);
if ($draft) {
$this->entityManager->remove($draft);
$this->entityManager->flush();
}
}
}
控制器实现
// src/Controller/FormController.php
namespace App\Controller;
use App\Entity\Article;
use App\Form\Type\ArticleType;
use App\Service\DraftService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class FormController extends AbstractController
{
#[Route('/article/new', name: 'article_new')]
#[Route('/article/{id}/edit', name: 'article_edit')]
public function new(Request $request, DraftService $draftService, ?int $id = null): Response
{
$article = $id ? $this->getArticle($id) : new Article();
$form = $this->createForm(ArticleType::class, $article);
// 自动保存草稿
$draftId = $request->query->get('draft');
if ($draftId) {
// 恢复草稿
$form = $draftService->getFormDataFromDraft($form, $draftId);
}
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// 如果是保存草稿
if ($form->get('save_draft')->isClicked()) {
$draft = $draftService->saveDraft(
ArticleType::class,
$form->getData(),
$request->getSession()->get('current_draft_id')
);
$request->getSession()->set('current_draft_id', $draft->getId());
$this->addFlash('success', '草稿已保存');
return $this->redirectToRoute('article_new', ['draft' => $draft->getId()]);
}
// 实际提交
// ... 保存到数据库
// 删除草稿
if ($draftId = $request->getSession()->remove('current_draft_id')) {
$draftService->deleteDraft($draftId);
}
return $this->redirectToRoute('article_list');
}
// 获取用户的所有草稿
$userDrafts = $draftService->getUserDrafts(ArticleType::class);
return $this->render('article/form.html.twig', [
'form' => $form->createView(),
'userDrafts' => $userDrafts,
'currentDraftId' => $request->getSession()->get('current_draft_id'),
]);
}
#[Route('/draft/load/{id}', name: 'draft_load')]
public function loadDraft(int $id, DraftService $draftService): Response
{
$draft = $draftService->loadDraft($id);
if (!$draft) {
throw $this->createNotFoundException('草稿不存在');
}
return $this->redirectToRoute('article_new', ['draft' => $id]);
}
#[Route('/draft/delete/{id}', name: 'draft_delete')]
public function deleteDraft(int $id, DraftService $draftService): Response
{
$draftService->deleteDraft($id);
$this->addFlash('success', '草稿已删除');
return $this->redirectToRoute('article_new');
}
}
表单类型
// src/Form/Type/ArticleType.php
namespace App\Form\Type;
use App\Entity\Article;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ArticleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => '标题',
])
->add('content', TextareaType::class, [
'label' => '内容',
])
->add('save_draft', SubmitType::class, [
'label' => '保存草稿',
'attr' => ['class' => 'btn-secondary'],
])
->add('submit', SubmitType::class, [
'label' => '发布',
'attr' => ['class' => 'btn-primary'],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Article::class,
]);
}
}
前端实现
{# templates/article/form.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<div class="container mt-4">
<h1>{{ form.vars.value.id ? '编辑文章' : '新建文章' }}</h1>
{% if userDrafts|length > 0 %}
<div class="alert alert-info">
<h5>草稿列表</h5>
<div class="list-group">
{% for draft in userDrafts %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<a href="{{ path('draft_load', {'id': draft.id}) }}" class="text-decoration-none">
草稿 #{{ draft.id }}
({{ draft.updatedAt|date('Y-m-d H:i:s') }})
</a>
<span>
<a href="{{ path('draft_delete', {'id': draft.id}) }}"
class="btn btn-sm btn-danger"
onclick="return confirm('确定删除此草稿?')">
删除
</a>
</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{{ form_start(form, {'attr': {'class': 'needs-validation', 'novalidate': 'novalidate'}}) }}
{{ form_widget(form) }}
{{ form_end(form) }}
</div>
<!-- 自动保存功能 -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('form');
let autoSaveTimer;
// 监听表单输入变化
form.addEventListener('input', function() {
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(autoSaveDraft, 30000); // 30秒自动保存
});
function autoSaveDraft() {
const formData = new FormData(form);
const data = {};
formData.forEach((value, key) => {
// 排除提交按钮
if (!key.includes('submit') && !key.includes('save_draft')) {
data[key] = value;
}
});
// 发送AJAX请求保存草稿
fetch('{{ path('draft_autosave') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('草稿已自动保存');
}
})
.catch(error => console.error('自动保存失败:', error));
}
// 显示通知的函数
function showNotification(message) {
const notification = document.createElement('div');
notification.className = 'alert alert-success alert-dismissible fade show position-fixed top-0 end-0 m-3';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
});
</script>
{% endblock %}
自动保存API端点
// src/Controller/ApiController.php
namespace App\Controller;
use App\Service\DraftService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class ApiController extends AbstractController
{
#[Route('/draft/autosave', name: 'draft_autosave', methods: ['POST'])]
public function autoSave(Request $request, DraftService $draftService): JsonResponse
{
try {
$data = json_decode($request->getContent(), true);
if (!$data) {
return $this->json(['success' => false, 'error' => '无效的数据'], 400);
}
// 获取当前草稿ID(如果存在)
$currentDraftId = $request->getSession()->get('current_draft_id');
$draft = $draftService->saveDraft(
'App\\Form\\Type\\ArticleType',
$data,
$currentDraftId
);
$request->getSession()->set('current_draft_id', $draft->getId());
return $this->json([
'success' => true,
'draftId' => $draft->getId(),
'message' => '草稿已自动保存'
]);
} catch (\Exception $e) {
return $this->json([
'success' => false,
'error' => $e->getMessage()
], 500);
}
}
}
配置服务
# config/services.yaml
services:
App\Service\DraftService:
arguments:
$security: '@security.helper'
tags:
- { name: 'kernel.event_listener', event: 'kernel.request', method: 'onKernelRequest' }
这个完整的解决方案包括:
- 数据库存储 - 存储表单数据和元数据
- 草稿服务 - 处理草稿的保存、加载和删除
- 控制器 - 处理草稿的恢复和自动保存
- 前端 - 显示草稿列表和自动保存功能
- 安全考虑 - 根据用户或会话隔离草稿
你可以根据具体需求调整自动保存间隔、数据序列化方式和其他功能细节。