Sonata Admin列表视图中按Root元素分组的折叠数据集实现咨询
嘿,这个需求我之前帮人实现过,在Sonata Admin里完全可以搞定,核心思路是自定义列表模板+前端折叠交互,再配合实体关联的正确配置。我给你一步步讲清楚:
第一步:确认实体的自关联配置正确
首先你的Person实体需要正确设置自引用关联,确保Root Person能关联到下属。这里是Doctrine注解的示例:
// src/Entity/Person.php namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class Person { // 其他基础字段(id、name、email等)省略... #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'subordinates')] #[ORM\JoinColumn(name: 'root_person_id', nullable: true)] private ?self $rootPerson = null; #[ORM\OneToMany(mappedBy: 'rootPerson', targetEntity: self::class)] private Collection $subordinates; public function __construct() { $this->subordinates = new ArrayCollection(); } // 对应的getter和setter方法 public function getRootPerson(): ?self { return $this->rootPerson; } public function setRootPerson(?self $rootPerson): self { $this->rootPerson = $rootPerson; return $this; } /** * @return Collection<int, self> */ public function getSubordinates(): Collection { return $this->subordinates; } public function addSubordinate(self $subordinate): self { if (!$this->subordinates->contains($subordinate)) { $this->subordinates->add($subordinate); $subordinate->setRootPerson($this); } return $this; } public function removeSubordinate(self $subordinate): self { if ($this->subordinates->removeElement($subordinate)) { // 解除关联避免ORM报错 if ($subordinate->getRootPerson() === $this) { $subordinate->setRootPerson(null); } } return $this; } }
第二步:配置Sonata Admin只显示Root Person
在你的PersonAdmin类里,我们需要修改列表查询,只返回rootPerson为null的实体(也就是根节点),同时指定自定义的列表模板:
// src/Admin/PersonAdmin.php namespace App\Admin; use Sonata\AdminBundle\Admin\AbstractAdmin; use Sonata\AdminBundle\Datagrid\ListMapper; use Sonata\AdminBundle\Datagrid\ProxyQueryInterface; use Sonata\AdminBundle\Route\RouteCollectionInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; class PersonAdmin extends AbstractAdmin { // 覆盖默认的列表模板 public function getTemplate($name) { if ($name === 'list') { return '@AppAdmin/Person/list.html.twig'; } return parent::getTemplate($name); } protected function configureListFields(ListMapper $listMapper): void { $listMapper ->addIdentifier('name') ->add('email'); // 可添加其他需要显示的字段 } // 重写查询逻辑,只获取根节点 protected function createQuery($context = 'list'): ProxyQueryInterface { $query = parent::createQuery($context); $query->andWhere($query->expr()->isNull($this->getClassMetadata()->getFieldName('rootPerson'))); return $query; } // (可选)添加AJAX加载下属的路由 protected function configureRoutes(RouteCollectionInterface $collection): void { $collection->add('get_subordinates', $this->getRouterIdParameter() . '/subordinates'); } // (可选)AJAX加载下属的action方法 public function getSubordinatesAction(Request $request) { $person = $this->getSubject(); $html = $this->renderView('@AppAdmin/Person/subordinates_table.html.twig', [ 'subordinates' => $person->getSubordinates(), 'admin' => $this, ]); return new JsonResponse(['html' => $html]); } }
第三步:自定义列表模板实现折叠效果
这里分两种方案,根据你的数据量选择:
方案1:一次性加载下属(适合数据量小的场景)
创建templates/admin/Person/list.html.twig:
{% extends '@SonataAdmin/CRUD/list.html.twig' %} {% block table_body %} {% for object in pagination %} <tr class="root-person-row"> <td colspan="{{ list_fields|length + 1 }}"> <div class="d-flex justify-content-between align-items-center p-2 bg-light rounded"> <div> <strong>{{ object.name }}</strong> - {{ object.email }} </div> <button class="btn btn-sm btn-outline-secondary toggle-btn" data-target="#subordinates-{{ object.id }}"> 展开 </button> </div> <!-- 下属面板,默认隐藏 --> <div id="subordinates-{{ object.id }}" class="subordinates-panel mt-2 ms-4" style="display: none;"> <table class="table table-sm table-bordered"> <thead> <tr> <th>姓名</th> <th>邮箱</th> <th>操作</th> </tr> </thead> <tbody> {% for subordinate in object.subordinates %} <tr> <td>{{ subordinate.name }}</td> <td>{{ subordinate.email }}</td> <td> <a href="{{ admin.generateObjectUrl('edit', subordinate) }}" class="btn btn-sm btn-primary">编辑</a> <a href="{{ admin.generateObjectUrl('show', subordinate) }}" class="btn btn-sm btn-info ms-1">查看</a> </td> </tr> {% endfor %} </tbody> </table> </div> </td> </tr> {% endfor %} {% endblock %} {% block javascripts %} {{ parent() }} <script> document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.toggle-btn').forEach(btn => { btn.addEventListener('click', function() { const target = document.querySelector(this.dataset.target); if (target.style.display === 'none') { target.style.display = 'block'; this.textContent = '收起'; } else { target.style.display = 'none'; this.textContent = '展开'; } }); }); }); </script> {% endblock %}
方案2:AJAX动态加载下属(适合数据量大的场景)
先创建下属列表的子模板templates/admin/Person/subordinates_table.html.twig:
<table class="table table-sm table-bordered"> <thead> <tr> <th>姓名</th> <th>邮箱</th> <th>操作</th> </tr> </thead> <tbody> {% for subordinate in subordinates %} <tr> <td>{{ subordinate.name }}</td> <td>{{ subordinate.email }}</td> <td> <a href="{{ admin.generateObjectUrl('edit', subordinate) }}" class="btn btn-sm btn-primary">编辑</a> <a href="{{ admin.generateObjectUrl('show', subordinate) }}" class="btn btn-sm btn-info ms-1">查看</a> </td> </tr> {% endfor %} </tbody> </table>
然后修改主列表模板list.html.twig的JavaScript部分:
{% extends '@SonataAdmin/CRUD/list.html.twig' %} {% block table_body %} {% for object in pagination %} <tr class="root-person-row"> <td colspan="{{ list_fields|length + 1 }}"> <div class="d-flex justify-content-between align-items-center p-2 bg-light rounded"> <div> <strong>{{ object.name }}</strong> - {{ object.email }} </div> <button class="btn btn-sm btn-outline-secondary toggle-btn" data-person-id="{{ object.id }}"> 展开 </button> </div> <!-- 下属面板,默认隐藏且为空 --> <div id="subordinates-{{ object.id }}" class="subordinates-panel mt-2 ms-4" style="display: none;"></div> </td> </tr> {% endfor %} {% endblock %} {% block javascripts %} {{ parent() }} <script> document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.toggle-btn').forEach(btn => { btn.addEventListener('click', async function() { const personId = this.dataset.personId; const panel = document.getElementById(`subordinates-${personId}`); if (panel.style.display === 'none') { // 如果面板为空,先请求数据 if (!panel.innerHTML.trim()) { const url = '{{ admin.generateUrl('get_subordinates', {id: '__ID__'}) }}'.replace('__ID__', personId); const response = await fetch(url); const data = await response.json(); panel.innerHTML = data.html; } panel.style.display = 'block'; this.textContent = '收起'; } else { panel.style.display = 'none'; this.textContent = '展开'; } }); }); }); </script> {% endblock %}
额外优化:支持多级折叠(如果需要)
如果你的Person是多级结构(下属还有自己的下属),可以用Twig宏来递归渲染:
{# 在list.html.twig里添加递归宏 #} {% macro renderSubordinates(subordinates, admin) %} <table class="table table-sm table-bordered mt-2"> <tbody> {% for subordinate in subordinates %} <tr> <td> <div class="d-flex justify-content-between align-items-center"> <span>{{ subordinate.name }} - {{ subordinate.email }}</span> {% if subordinate.subordinates|length > 0 %} <button class="btn btn-xs btn-outline-secondary toggle-child-btn" data-target="#child-subordinates-{{ subordinate.id }}"> 展开 </button> {% endif %} </div> {% if subordinate.subordinates|length > 0 %} <div id="child-subordinates-{{ subordinate.id }}" class="ms-4" style="display: none;"> {{ _self.renderSubordinates(subordinate.subordinates, admin) }} </div> {% endif %} </td> <td> <a href="{{ admin.generateObjectUrl('edit', subordinate) }}" class="btn btn-sm btn-primary">编辑</a> </td> </tr> {% endfor %} </tbody> </table> {% endmacro %} {# 在下属面板里调用宏 #} <div id="subordinates-{{ object.id }}" class="subordinates-panel mt-2 ms-4" style="display: none;"> {{ _self.renderSubordinates(object.subordinates, admin) }} </div>
记得同步更新JavaScript来处理子级的折叠按钮哦。
内容的提问来源于stack exchange,提问作者Jim Panse
相关产品推荐
相关产品推荐

