返回

Craft CMS 4 插件开发:解决 Ajax 请求问题

php

Craft CMS 4 插件开发:解决 Ajax 请求无法触发自定义方法的问题

问题

我在 Craft CMS 4 中创建了一个自定义插件,用于解析 JSON 数据并将其存储到相应的条目中。 起初我在 init 函数中调用了解析方法,导致每次页面加载都会触发,这显然没必要。 现在我希望通过点击 Twig 模板中的按钮来触发该方法,通过 Ajax 发送请求。 但不知道为啥,就是无法触发实际的函数。 代码如上所示。

问题分析

问题可能出在几个方面:

  1. 路由配置错误: Craft CMS 通过路由将 URL 请求映射到相应的控制器和方法。 你的 EVENT_REGISTER_CP_URL_RULES 事件监听器可能配置不正确,导致请求无法到达目标方法。 路由规则'_jsonify/import/test' 很奇怪。
  2. 控制器方法命名: 控制器的方法应该以 action 开头,Craft CMS才能正确识别. 你的控制器方法 testactionHandleJsonRead 没有用action作为前缀。 并且是private私有方法,外部无法访问.
  3. 表单提交方式: Twig 模板中的表单可能没有正确地将请求发送到预期的控制器和方法。你需要确认 actionInput 的值是否正确。
  4. CSRF Token: Craft CMS 使用 CSRF 保护来防止跨站请求伪造攻击。 如果没有正确处理 CSRF Token,Ajax 请求可能会被阻止。
  5. 插件入口函数 init()中的额外逻辑 : 不应该在插件的 init() 方法中直接执行 getJsonFiledecodeJsonFile, 它们应该被整合到控制器方法中.

解决方案

针对以上分析,我们逐一解决这些问题:

1. 修改路由配置

Plugin.php 中的路由配置更改为更标准的 Craft CMS 路由格式:

// Plugin.php

use Craft;
use craft\events\RegisterCpNavItemsEvent;
use craft\web\twig\variables\Cp;
use craft\web\View;
use craft\events\RegisterTemplateRootsEvent;
use craft\events\RegisterUrlRulesEvent;
use craft\web\UrlManager;
use yii\base\Event;

public function init(): void
{
    parent::init();

    // Defer further initialization until Craft is fully initialized
    Craft::$app->onInit(function() {
         $this->attachEventHandlers();
         // ... other initialization tasks ...
     });
}

private function attachEventHandlers(): void
{

    // Register a custom template root path
    Event::on(
        View::class,
        View::EVENT_REGISTER_SITE_TEMPLATE_ROOTS,
        function(RegisterTemplateRootsEvent $event) {
            $event->roots['_jsonify'] = __DIR__ . '/templates';
        }
    );
        // 注册侧边栏
        Event::on(
            Cp::class,
            Cp::EVENT_REGISTER_CP_NAV_ITEMS,
            function(RegisterCpNavItemsEvent $event) {
                $event->navItems[] = [
                    'url' => '_jsonify/index',  // 确保这个 URL 与下面的路由匹配
                    'label' => 'Jsonify',
                    'icon' => '@_jsonify/icon.svg',
                ];
            }
        );

    // Register the event that should be triggered on this url.
    Event::on(
        UrlManager::class,
        UrlManager::EVENT_REGISTER_CP_URL_RULES,
        function(RegisterUrlRulesEvent $event) {
            $event->rules['_jsonify/index'] = '_jsonify/import/index'; // twig页面渲染
            $event->rules['POST _jsonify/read-json'] = '_jsonify/import/handle-json-read'; //处理按钮点击post事件.
        }
    );

}
  • 使用EVENT_REGISTER_CP_NAV_ITEMS将插件入口添加到侧边导航栏。
  • icon.svg文件放置在src/templates/目录下, 或者使用绝对路径.

2. 修改控制器方法

修改 JsonifySettingsController.php:

<?php
namespace plugins\jsonify\controllers;

use Craft;
use craft\web\Controller;
use craft\elements\Entry;
use yii\web\Response;
use craft\web\Request;

class ImportController extends Controller
{

    protected array|bool|int $allowAnonymous = ['index', 'handle-json-read']; //允许匿名访问, 用于测试.

    // 渲染主页面
    public function actionIndex(): Response|string
    {
        $folderId = 1; // 假设你的 JSON 文件存储在 ID 为 1 的文件夹中
        $assets = Craft::$app->assets->findFilesAndFolders(['folderId' => $folderId, 'kind' => 'json']);

        return $this->renderTemplate('_jsonify/index', [  // '_jsonify/index' 与你设置的 template roots 对应
            'assets' => $assets,
        ]);
    }

    // 处理 JSON 读取的请求
    public function actionHandleJsonRead(): Response
    {
         // 添加 CSRF 验证 (如果不是 CLI 请求)
         if (!Craft::$app->getRequest()->getIsConsoleRequest())
         {
              $this->requirePostRequest();
              Craft::$app->getRequest()->validateCsrfToken();
         }

        $jsonFile = $this->getJsonFile();  //从assets里选择了一个文件
        $dataArray = $this->decodeJsonFile($jsonFile);  //对文件内容解码

        $section = Craft::$app->sections->getSectionByHandle('test'); // 确保你有这个 section

         if (!$section) {
                return $this->asJson(['success' => false, 'message' => 'Section "test" not found.']);
         }

        $successCount = 0;
        $skipCount = 0;
        $errorMessages = [];

        foreach ($dataArray as $data) {

            $existingEntry = Entry::find()
            ->sectionId($section->id)
            ->andWhere(['title' => $data['Trial_name']])
            ->one();

            if ($existingEntry) {
                $skipCount++;
                continue; // 跳过重复的
            }

            $entry = new Entry();
            $entry->sectionId = $section->id;
            $entry->typeId = $section->getEntryTypes()[0]->id; // 获取section的第一个Entry Type
            $entry->authorId = 1;  // 设置作者 ID,通常是管理员
            $entry->title = $data['Trial_name'];
            $entry->setFieldValues([ //设置你的自定义字段.
                'testId' => $data['testID'],
                'entryName' => $data['Name'],
                'contractorSampleAnalysis' => $data['Contractor_sample_analysis'],
                'country' => $data['Country'],
                'crops' => $data['Crops'],
                'deltaYield' => $data['Delta_yield'],
                'lat' => $data['lat'],
                'location' => $data['Location'],
                'locationXy' => $data['location_XY'],
                'lon' => $data['lon'],
                'mapExport' => $data['Map_export'],
                'primaryCompany' => $data['Primary_company'],
                'primaryContact' => $data['Primary_contact'],
                'sizeHa' => $data['Size_ha'],
                'specsTrial' => $data['Specs_trial'],
                'entryStatus' => $data['Status'],
                'summaryResults' => $data['Summary_results'],
                'testType' => $data['Test_type'],
                'variety' => $data['Variety'],
                'year' => $data['Year'],
                'entryId' => $data['ID'],
                'internalSpecsTrial' => $data['Internal_specs_trial'],
                'prio' => $data['Prio'],
                'trialName' => $data['Trial_name'],
                'xy' => $data['XY'],
                'internalStatusTodo' => $data['Internal_status_todo'],
                'samplingAnalysis' => $data['Sampling_Analysis'],
                'statusObservations' => $data['Status_Observations'],
                'subsidyProject' => $data['Subsidy_project'],
                'xyRandomiser' => $data['XY_randomiser'],

            ]);


            if (Craft::$app->elements->saveElement($entry)) {
                $successCount++;
            } else {
                $errorMessages[] = 'Entry: "' . $entry->title .'" Error: ' . implode(', ', $entry->getErrorSummary(true));
            }

    }

        // 构造响应消息
        $message = "$successCount entries created.";
        if ($skipCount > 0) {
            $message .= " $skipCount entries skipped (duplicates).";
        }
        if (!empty($errorMessages)) {
             $message .= " Errors: " . implode(";",$errorMessages);

             return $this->asJson(['success' => false, 'message' => $message ]);

        }


        return $this->asJson(['success' => true, 'message' => $message]);
    }
     //假设读取的JSON文件放在资源文件夹里, ID=1.  实际中,可以根据form表单传入 assetId来处理不同的文件.
    private function getJsonFile()
    {
         $folderId = 1;
         $assets = Craft::$app->assets->findFilesAndFolders(['folderId' => $folderId, 'kind' => 'json']);

         //选择第一个文件.
        if(!empty($assets) && isset($assets[0]))
        {
           return  $assets[0];
        }

        return null;
    }

      /**
      * @param $jsonAsset  craft\elements\Asset
      */
    private function decodeJsonFile($jsonAsset)
    {
          if(!$jsonAsset)
          {
            return [];
          }
           $filePath = Craft::$app->getAssets()->getAssetFileAbsolutePath($jsonAsset);

            //检查文件是否存在并且可以读取
           if (file_exists($filePath) && is_readable($filePath))
           {
                $jsonContent = file_get_contents($filePath);

                 try {
                    $data = json_decode($jsonContent, true, 512, JSON_THROW_ON_ERROR); // 使用 JSON_THROW_ON_ERROR 来捕获解码错误
                    return $data;
                } catch (\JsonException $e) {
                    // 处理 JSON 解码错误,这里直接返回空, 可改为Craft Log 写入.
                    //  Craft::error('JSON decoding error: ' . $e->getMessage(), __METHOD__);
                    return [];

                }
           }
           else{
               //处理读取错误.
               return [];
           }
    }
}

  • 所有公开的方法都以 action 为前缀。
  • 添加了 protected array|bool|int $allowAnonymous = true; 以允许匿名访问handle-json-read操作进行测试(实际生产环境中,应该移除或者设置为false, 进行更严格的权限控制)。
  • 使用了 decodeJsonFile 方法来安全地读取和解码 JSON 数据,加入了错误处理。
  • 新增了actionIndex来显示主页。
  • test方法可以删除掉了。
  • requirePostRequest()validateCsrfToken()保证安全性。

3. 更新 Twig 模板

{# templates/_jsonify/index.twig #}

{% extends "_layouts/cp.twig" %}

{% set title = "Jsonify"|t('_jsonify') %}

{% set fullPageForm = true %}

{% block content %}
    {% if assets|length %}
        <ul>
            {% for asset in assets %}
               {% if asset.kind == 'file' %}
                <li style="width: auto; display: flex; justify-content: space-between; align-items: center">
                      {{ asset.filename }}
                        <button
                            style="margin-left: 30px; background: #d3d3d3; padding: 3px 12px; border-radius: 4px"
                            class="json-button"
                            data-asset-id="{{ asset.id }}"
                            >
                            Read JSON
                        </button>
                </li>
                 {% endif %}
            {% endfor %}
        </ul>
    {% else %}
        <p>No assets found.</p>
    {% endif %}

<script>
    document.querySelectorAll('.json-button').forEach(button => {
        button.addEventListener('click', function(event) {
            event.preventDefault();

            let assetId = this.dataset.assetId; // 获取 data-asset-id 属性

            let data = {
              [Craft.csrfTokenName]: Craft.csrfTokenValue, // 包含 CSRF token
               assetId : assetId
            }

            fetch('{{ url("_jsonify/read-json") }}', {  // 使用 url() 函数生成 URL,而不是 actionInput
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded', //经典form表单格式,简单.
                    'X-Requested-With': 'XMLHttpRequest', // 告诉 Craft 这是一个 AJAX 请求
                },

                body:  new URLSearchParams(data).toString()

            })
            .then(response => response.json())
            .then(data => {
                 // 在这里处理响应数据, 显示消息.
                alert(data.message);
            })
            .catch((error) => {
                console.error('Error:', error);
                alert('An error occurred. Please check the console for details.'); //可以更友好提示.
            });
        });
    });
</script>
{% endblock %}
  • 使用 JavaScript 和 fetch API 来发送 AJAX 请求。
  • 从按钮的data-asset-id获取assetId,通过POST请求传给服务器。实际情况请酌情处理,保证安全。
  • actionInput 被删除,使用标准的 url() 函数。
  • 使用了URLSearchParams 来构建post 数据。
  • 正确包含了 CSRF Token。
  • 添加了 JavaScript 代码来处理按钮点击事件,发送 AJAX 请求,并处理响应。
  • 通过 headers 中的 X-Requested-With: XMLHttpRequest, 告诉Craft这是一个Ajax 请求.

4. 安全建议

  • 输入验证: 始终验证和清理从外部源(如 JSON 文件)接收到的数据。防止恶意数据注入。
  • 错误处理: 完善错误处理机制,记录详细的错误日志,方便调试和排查问题。
  • 权限控制: 生产环境下,限制对 actionHandleJsonRead 方法的访问。
  • CSRF : 使用 Craft 提供的 CSRF 防护,并在前端发送请求时包含 CSRF token.
  • X-Requested-With : 在Ajax请求的headers里加上 X-Requested-With: XMLHttpRequest, 防止某些类型的跨站攻击。

进阶使用技巧

  • 使用Craft 内置的队列功能. 如果JSON文件特别大,可以把解析和数据导入的过程放入 Craft CMS 的队列 (Queue) 中,防止阻塞主线程,提升用户体验。
  • 错误信息方面, 可以使用 Flash messages 给用户更友好的操作提示.

通过以上修改,你的 Craft CMS 4 插件应该可以正常工作了。 点击按钮,就能触发 actionHandleJsonRead 方法来读取和处理 JSON 数据,并将数据正确导入到 Craft CMS 的条目中.