Sulu CMS 中为导航项动态注入页面图标数据的实现方案

1次阅读

Sulu CMS 中为导航项动态注入页面图标数据的实现方案

本文介绍如何在 Sulu cms 中为导航 API 响应动态添加页面专属图标字段(如 navigationIcon),绕过 HeadlessBundle 默认不序列化自定义扩展数据的限制,通过内核响应监听器精准注入结构化图标信息。

本文介绍如何在 sulu cms 中为导航 api 响应动态添加页面专属图标字段(如 `navigationicon`),绕过 headlessbundle 默认不序列化自定义扩展数据的限制,通过内核响应监听器精准注入结构化图标信息。

在 Sulu CMS 中,为页面配置导航图标(如用于菜单项的 SVG 或图标类名)是一项常见需求,但原生 HeadlessBundle 的 /api/navigations 接口默认不会序列化页面文档的自定义扩展数据(Extensions Data),即使你已在后台表单中通过自定义 Tab 成功保存了图标配置(例如 navigation.icon 字段)。用户无法直接在 excerpt 区域受限选择系统图库图标,而 single_media_selection 也不支持绑定到特定系统集合(如 system-icons),这使得纯配置化方案难以落地。

解决方案的核心思路是:不在序列化层“修补”HeadlessBundle,而是在响应生成后、返回客户端前,动态增强 json 数据。我们利用 symfony 的 kernel.response 事件,在 HeadlessBundle 导航 API 响应被发送前拦截并注入图标字段。

✅ 实现步骤概览

  1. 前端配置:通过 Sulu 自定义 Admin Tab 在页面编辑界面添加图标选择字段(例如使用 select 类型预设图标名,或 single_media_selection 并配合前端校验限制上传);
  2. 数据存储:将图标标识(如 icon-name, fa-solid fa-home, 或媒体 UUID)存入页面文档的 extensions 属性中(如 navigation.icon);
  3. 响应增强:注册 kernel.response 监听器,仅针对 sulu_headless.api.navigation 路由生效,解析原始 JSON 响应,逐个加载对应页面文档,提取 extensions.navigation.icon 并注入到每个导航项中。

? 示例监听器代码(推荐放入 src/EventListener/NavigationListener.php)

<?php declare(strict_types=1);  namespace AppEventListener;  use SuluComponentDocumentManagerDocumentManagerInterface; use SuluComponentDocumentManagerExceptionDocumentManagerException; use SymfonyComponentHttpKernelEventResponseEvent;  class NavigationListener {     public function __construct(private DocumentManagerInterface $documentManager)     {     }      public function onKernelResponse(ResponseEvent $event): void     {         $request = $event->getRequest();         // 仅作用于 Headless 导航 API         if ($request->attributes->get('_route') !== 'sulu_headless.api.navigation') {             return;         }          $response = $event->getResponse();         $content = $response->getContent();          // 安全解析 JSON(生产环境建议加 try-catch)         $data = json_decode($content, true);         if (json_last_error() !== JSON_ERROR_NONE || !isset($data['_embedded']['items'])) {             return;         }          // 遍历每个导航项,注入 navigationIcon         foreach ($data['_embedded']['items'] as &$item) {             if (!isset($item['uuid'])) {                 continue;             }              try {                 $document = $this->documentManager->find($item['uuid']);                 $extensions = $document->getExtensionsData()->toArray();                  // 提取 extensions.navigation.icon(确保结构存在)                 if (                     isset($extensions['navigation']['icon']) &&                     is_scalar($extensions['navigation']['icon'])                 ) {                     $item['navigationIcon'] = (string) $extensions['navigation']['icon'];                 }             } catch (DocumentManagerException $e) {                 // 日志记录异常(如文档不存在),避免中断整个响应                 error_log(sprintf('Failed to load document %s: %s', $item['uuid'], $e->getMessage()));                 continue;             }         }          // 重新设置响应内容(保持原有 headers,如 Content-Type)         $response->setContent(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));     } }

⚙️ 服务注册(Symfony 6+/7+ YAML 格式)

确保监听器在容器中正确注册并启用事件订阅:

# config/services.yaml services:     AppEventListenerNavigationListener:         tags:             - { name: 'kernel.event_listener', event: 'kernel.response', method: 'onKernelResponse' }

✅ 最终效果(API 响应片段)

{   "_embedded": {     "items": [       {         "id": "ffffffff-ffff-ffff-ffff-fffffffffff",         "uuid": "ffffffff-ffff-ffff-ffff-fffffffffff",         "title": "Home",         "url": "/",         "navigationIcon": "home-outline"  // ← 新增字段,值来自 extensions.navigation.icon       },       {         "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",         "uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",         "title": "About",         "url": "/about",         "navigationIcon": "fa-solid fa-info-circle"       }     ]   } }

⚠️ 注意事项与最佳实践

  • 性能考量:该方案对每个导航项触发一次文档加载,若导航层级深或项数多(>50),建议结合缓存(如 Doctrine Result Cache 或 PSR-6 缓存 DocumentManagerInterface::find() 结果);
  • 扩展健壮性:务必检查 extensions 数组结构是否存在,避免 Notice: undefined index;生产环境应捕获 DocumentManagerException 并降级处理;
  • 类型安全:navigationIcon 值应为字符串(图标类名、文件名或 UUID),避免传入数组或对象;前端消费时可统一做空值 fallback;
  • 未来兼容性:Sulu 团队已意识到扩展数据未透出的问题,长期建议关注 HeadlessBundle 扩展支持 RFC —— 本方案是当前稳定版(2.4+)的可靠过渡方案;
  • 替代思路(轻量级):若图标集极小且静态(如仅 5 个选项),也可用 page_property + select 类型字段替代 extensions,并在监听器中读取 $doc->getProperty(‘navigation_icon’),逻辑更简单。

通过此方案,你无需修改 Sulu 核心、不侵入 HeadlessBundle 序列化逻辑,即可在保持管理后台体验可控的前提下,向前端交付结构清晰、语义明确的导航图标数据,真正实现「所配即所得」。

text=ZqhQzanResources