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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.05.25 03:40:32