Skip to content

让我们构建一个插件#

对于一些人来说,直接投入项目是学习的最佳方式,而接下来的章节将教你如何从头开始构建一个插件。做好准备;这不是一个简单的“Hello world”类型的演示。这实际上是一个功能相当全面的演示插件,涵盖了XF2中的许多概念。

我们将构建的插件将允许具有适当权限的用户“推荐”一个帖子,并将该帖子显示在一个新页面上。我们甚至会设置一个流程,自动推荐特定论坛中的帖子。我们将使用一个名为portal的新路由,并最终将其设置为首页路由,并在查看该页面时选择“首页”选项卡。

创建插件#

在整个插件中,我们将使用插件ID Demo/Portal。首先,我们需要创建插件,为此我们需要打开命令提示符/ shell /终端窗口,将目录更改为XF安装根目录(cmd.php所在的位置),并运行以下命令,然后输入如下所示的响应:

终端
php cmd.php xf-addon:create

终端输出

输入此插件的ID: Demo/Portal

输入标题: Demo - Portal

输入版本ID: 此整数将用于内部变量比较。 每个插件版本都应增加此数字: 1000010

版本字符串设置为: 1.0.0 Alpha

此插件是否取代了XenForo 1插件? (y/n) n

addon.json文件已成功写入/var/www/src/addons/Demo/Portal/addon.json

您的插件是否需要设置文件? (y/n) y

您的设置是否需要支持运行多个步骤? (y/n) y

Setup.php文件已成功写入/var/www/src/addons/Demo/Portal/Setup.php

插件现已创建,您将在src/addons目录中找到一个新目录,并在Admin CP的“已安装插件”列表中找到该插件。

已创建的文件之一是addon.json文件,目前如下所示:

src/addons/Demo/Portal/addon.json
{
    "legacy_addon_id": "",
    "title": "Demo - Portal",
    "description": "",
    "version_id": 1000010,
    "version_string": "1.0.0 Alpha",
    "dev": "",
    "dev_url": "",
    "faq_url": "",
    "support_url": "",
    "extra_urls": [],
    "require": [],
    "icon": ""
}

让我们填写一些详细信息:

src/addons/Demo/Portal/addon.json
{
    "legacy_addon_id": "",
    "title": "Demo - Portal",
    "description": "该插件将在论坛首页显示推荐的帖子。",
    "version_id": 1000010,
    "version_string": "1.0.0 Alpha",
    "dev": "你!",
    "dev_url": "",
    "faq_url": "",
    "support_url": "",
    "extra_urls": [],
    "require": [],
    "icon": "fa-home"
}

我们现在添加了description,开发者的名字(dev),并指定我们想要显示一个图标(icon)。图标可以是路径(相对于插件根目录)或Font Awesome图标的名称,就像我们在这里所做的那样。

由于我们没有取代XenForo 1插件,我们可以忽略legacy_addon_id。有关addon.json文件中所有属性的完整说明,请参阅插件结构部分

创建设置类#

严格来说,该类已经创建并写入Setup.php,但现在它并没有真正做任何事情。我们基本上有一个骨架类,如下所示:

src/addons/Demo/Portal/Setup.php
<?php

namespace Demo\Portal;

use XF\AddOn\AbstractSetup;
use XF\AddOn\StepRunnerInstallTrait;
use XF\AddOn\StepRunnerUninstallTrait;
use XF\AddOn\StepRunnerUpgradeTrait;

class Setup extends AbstractSetup
{
        use StepRunnerInstallTrait;
        use StepRunnerUpgradeTrait;
        use StepRunnerUninstallTrait;
}

我们已经讨论了一点关于设置类的内容。我们将把安装、升级和卸载过程分解为单独的步骤。

让我们首先导入一些有用的Schema类。如果你想了解更多关于它们的信息,可以参考管理Schema部分。 在最后一个use声明之后,添加以下行:

src/addons/Demo/Portal/Setup.php
use XF\Db\Schema\Alter;
use XF\Db\Schema\Create;

这里的StepRunner特性将处理遍历所有可用步骤的过程,所以我们只需要开始创建这些步骤。我们将首先添加一些代码来在xf_forum表中创建一个新列:

src/addons/Demo/Portal/Setup.php
<?php

namespace Demo\Portal;

use XF\AddOn\AbstractSetup;
use XF\AddOn\StepRunnerInstallTrait;
use XF\AddOn\StepRunnerUninstallTrait;
use XF\AddOn\StepRunnerUpgradeTrait;

use XF\Db\Schema\Alter;
use XF\Db\Schema\Create;

class Setup extends \XF\AddOn\AbstractSetup
{
    use StepRunnerInstallTrait;
    use StepRunnerUpgradeTrait;
    use StepRunnerUninstallTrait;

    public function installStep1()
    {
        $this->schemaManager()->alterTable('xf_forum', function(Alter $table)
        {
            $table->addColumn('demo_portal_auto_feature', 'tinyint')->setDefault(0);
        });
    }
}

此列被添加到xf_forum表中,以便我们可以设置某些论坛在创建帖子时自动推荐帖子。这里的命名很重要;添加到核心XF表中的列应始终带有前缀。这有两个重要目的。第一个是减少未来XF或其他插件有理由添加该列时发生冲突的风险。第二个是它有助于更容易地识别哪些列属于哪些插件,以防将来出现一些问题。

既然我们在这里,我们不妨向安装程序添加另一个步骤。为了简洁起见,我们只显示新代码,而不是整个类。它应该直接放在installStep1()方法下面:

src/addons/Demo/Portal/Setup.php
public function installStep2()
{
    $this->schemaManager()->alterTable('xf_thread', function(Alter $table)
    {
        $table->addColumn('demo_portal_featured', 'tinyint')->setDefault(0);
    });
}

此步骤与上面的步骤类似,这次将向xf_thread表添加一个新列。我们将使用此列作为缓存值,以快速识别帖子是否被推荐,而无需执行额外的查询或查找xf_demo_portal_featured_thread表。

说到这个,我们现在应该添加该表。这次直接放在installStep2()下面:

src/addons/Demo/Portal/Setup.php
public function installStep3()
{
    $this->schemaManager()->createTable('xf_demo_portal_featured_thread', function(Create $table)
    {
        $table->addColumn('thread_id', 'int');
        $table->addColumn('featured_date', 'int');
        $table->addPrimaryKey('thread_id');
    });
}

此步骤将创建新表。该表将用于记录所有被推荐的帖子以及它们被推荐的时间。

在命名方面,同样的原则适用。一个显著的区别是所有表都应额外加上xf_前缀。这样做的原因是,如果执行了干净的XF安装,我们可以删除所有带有xf_前缀的表,包括由插件创建的表。

在添加各种模式更改的代码时,最容易忘记的事情之一就是忘记自己应用模式更改。你可以使用CLI命令运行安装/升级步骤。在这种情况下,执行以下命令:

终端
php cmd.php xf-addon:install-step Demo/Portal 1
终端
php cmd.php xf-addon:install-step Demo/Portal 2
终端
php cmd.php xf-addon:install-step Demo/Portal 3

扩展论坛实体#

到目前为止,我们已经向xf_forum表添加了一列,现在是时候扩展论坛实体结构了。我们需要这样做,以便实体知道我们的新列,并且可以通过实体读取和写入数据。

注意

以下步骤需要启用开发模式。记得在config.php中将Demo/Portal设置为defaultAddOn值。

此过程的第一步是创建一个“代码事件监听器”。这可以在Admin CP的开发部分完成,点击“代码事件监听器”链接,然后点击“添加代码事件监听器”按钮。

我们需要监听entity_structure事件。我们将使用它来修改默认的论坛实体结构,以添加我们新创建的demo_portal_auto_feature列。

在“事件提示”字段中,我们将输入我们要扩展的类名,例如XF\Entity\Forum。这将确保我们的监听器仅在论坛实体上执行。

在“执行回调”类字段中输入Demo\Portal\Listener,在方法字段中输入forumEntityStructure

值得添加一个描述来解释此监听器的用途,因为这将有助于更容易地在代码事件监听器列表中识别该监听器。“扩展XF\Entity\Forum结构”应该足够了。最后,确保选择了“Demo - Portal”插件。

在我们点击“保存”之前,我们需要实际创建Listener类。因此,在src/addons/Demo/Portal中创建一个名为Listener.php的新文件。该文件的内容最初应如下所示。我们从代码事件选择器下方的文档中知道此函数所需的参数。

src/addons/Demo/Portal/Listener.php
<?php

namespace Demo\Portal;

use XF\Mvc\Entity\Entity;

class Listener
{
    public static function forumEntityStructure(\XF\Mvc\Entity\Manager $em, \XF\Mvc\Entity\Structure &$structure)
    {

    }
}

请注意namespaceclass名称之间的use声明。我们将多次引用此处声明的类,因此在此处声明它确实允许我们通过其更短的别名引用它,在这种情况下为Entity

这段代码实际上还没有做任何事情,但现在是一个保存代码事件监听器的好时机,所以继续点击“保存”按钮。

在我们向新函数添加一些功能代码之前,现在可能是查看开发输出系统如何工作的好时机。检查添加到插件目录中的新目录和文件。具体来说,_output/code_event_listeners目录中有一个新的JSON文件,它应该如下所示:

src/addons/Demo/Portal/_output/code_event_listeners/entity_structure_[hash].json
{
    "event_id": "entity_structure",
    "execute_order": 10,
    "callback_class": "Demo\\Portal\\Listener",
    "callback_method": "forumEntityStructure",
    "active": true,
    "hint": "XF\\Entity\\Forum",
    "description": "Extends the XF\\Entity\\Forum structure"
}

每当对监听器进行更改时,此文件将自动更新。

好的,让我们添加更多代码。回到Listener类中,将以下内容添加到forumEntityStructure函数中:

src/addons/Demo/Portal/Listener.php
$structure->columns['demo_portal_auto_feature'] = ['type' => Entity::BOOL, 'default' => false];

论坛实体现在知道我们的新列了,但在我们开始实现一种实际开始在该列上设置值的方法之前,我们还应该采取一些步骤。

扩展帖子实体#

同样,由于我们向xf_thread表添加了一个新列,我们应该让Thread实体知道这一点。这与我们上面所做的非常相似。

回到“添加代码事件监听器”并再次监听entity_structure。这次的“事件提示”将是XF\Entity\Thread。我们可以使用与之前相同的回调类(Demo\Portal\Listener),但这次方法将命名为threadEntityStructure。添加与之前类似的描述。在保存之前,我们应该添加代码,直接放在forumEntityStructure函数下面:

src/addons/Demo/Portal/Listener.php
public static function threadEntityStructure(\XF\Mvc\Entity\Manager $em, \XF\Mvc\Entity\Structure &$structure)
{
    $structure->columns['demo_portal_featured'] = ['type' => Entity::BOOL, 'default' => false];
}

这段代码几乎与我们添加到论坛实体结构中的代码相同;唯一的区别是列名。但是,我们还需要添加一些东西。我们应该创建一个实体关系,以便以后如果我们需要访问推荐的帖子实体(我们将在下一节中创建),我们可以轻松地通过查找器查询来做到这一点。在$structure->columns行下面添加:

src/addons/Demo/Portal/Listener.php
$structure->relations['FeaturedThread'] = [
    'entity' => 'Demo\Portal:FeaturedThread',
    'type' => Entity::TO_ONE,
    'conditions' => 'thread_id',
    'primary' => true
];

有关关系的更多信息,请参阅关系。点击“保存”以保存监听器。

创建一个新实体#

在上面installStep3()中,我们创建了一个新表。我们需要创建一个实体来与此表交互并创建新记录。因为这是一个全新的实体,我们不需要做任何事情,只需在src/addons/Demo/Portal/Entity/FeaturedThread.php中创建类,其骨架如下所示:

src/addons/Demo/Portal/Entity/FeaturedThread.php
<?php

namespace Demo\Portal\Entity;

use XF\Mvc\Entity\Structure;

class FeaturedThread extends \XF\Mvc\Entity\Entity
{

}

我们需要使用它来定义代表我们之前创建的xf_demo_portal_featured_thread表的实体结构。此实体的结构应如下所示:

src/addons/Demo/Portal/Entity/FeaturedThread.php
public static function getStructure(Structure $structure)
{
    $structure->table = 'xf_demo_portal_featured_thread';
    $structure->shortName = 'Demo\Portal:FeaturedThread';
    $structure->primaryKey = 'thread_id';
    $structure->columns = [
        'thread_id' => ['type' => self::UINT, 'required' => true],
        'featured_date' => ['type' => self::UINT, 'default' => time()]
    ];
    $structure->getters = [];
    $structure->relations = [
        'Thread' => [
            'entity' => 'XF:Thread',
            'type' => self::TO_ONE,
            'conditions' => 'thread_id',
            'primary' => true
        ],
    ];

    return $structure;
}

列列表可能根据我们之前编写的用于创建表的MySQL代码自解释。关系包括一个Thread关系,它将允许我们从该实体获取相关的帖子实体记录(甚至帖子实体关系)。

修改论坛编辑表单#

我们现在需要一种方法来修改forum_edit模板,以在那里添加一个新的复选框,该复选框最终可以写回我们现在创建的新列。我们将通过创建一个模板修改来实现这一点。这可以在Admin CP的外观部分完成,然后点击模板修改。点击“Admin”选项卡,然后点击“添加模板修改”按钮。

在“模板”字段中,输入“forum_edit”。这是我们需要修改的模板。

在“修改键”字段中,输入“demo_portal_forum_edit”。这是一个唯一键,用于标识您的模板修改。首选约定是,至少提到插件名称,然后是正在修改的模板名称。

“描述”字段应包含一些文本,以帮助您在查看模板修改列表时识别此修改的用途。“将自动推荐复选框添加到forum_edit模板”应该足够了。

当您在“模板”字段中输入模板名称时,您可能会注意到显示了模板内容的预览。我们需要使用它来识别我们复选框的首选位置。在查看论坛编辑页面时,您可能会注意到有一系列复选框,这看起来是一个合理的位置。

在此部分放置复选框的最简单方法是对顶部复选框进行简单替换,因此在“查找”字段中添加:

查找
<xf:option name="allow_posting"

在替换字段中:

替换
<xf:option name="demo_portal_auto_feature" selected="$forum.demo_portal_auto_feature"
    label="自动推荐此论坛中的帖子"
    hint="如果选中,在此论坛中发布的任何新帖子将自动被推荐。" />
$0

我们不需要担心创建短语,稍后我们可以处理这些短语。请注意,name属性与我们之前创建的列名匹配,更重要的是,复选框行的选中状态也从论坛实体读取新添加的列。

当我们稍后保存模板修改时,如果查找字段的内容与模板的任何部分匹配,它将被替换为替换字段的内容。我们实际上并没有删除我们匹配的内容,因为替换字段中的$0重新插入了匹配的文本。

我们可以使用“测试”按钮来检查替换是否按预期工作。当点击测试按钮时,将出现一个包含修改后模板的覆盖层。如果一切顺利,应该会突出显示一个绿色区域,其中包含我们要添加的新代码。

注意

这是一个相当简单的替换。对于更高级的匹配,您还可以使用“正则表达式”类型。详细解释如何使用正则表达式超出了本指南的范围,但网上有很多资源可能会有所帮助。

最后,点击保存以保存您的模板修改。如果一切顺利,当您返回到模板修改列表时,您将看到日志摘要显示1 / 0 / 0,因此表明修改成功应用了一次。一个更好的指标是它按计划工作,即转到Admin CP中“论坛”下列出的“节点”页面,并编辑现有论坛。我们新添加的模板修改现在应该出现。

扩展论坛保存过程#

我们已经有了一列,需要一个用户界面将数据输入到该列中,现在需要处理将数据保存到该列的操作。我们将通过扩展论坛控制器并扩展一个特殊方法来实现,这个方法在节点及其数据保存时会被调用。首先,我们创建一个“类扩展”,这在Admin CP的“开发”选项中可以找到。点击“添加类扩展”。

在这里,我们需要指定“基类名称”,即我们扩展的类名,这里为XF\Admin\Controller\Forum。同时,我们需要指定“扩展类名”,即扩展基类的类名,输入为Demo\Portal\XF\Admin\Controller\Forum。点击保存前,确保已创建这个类。

src/addons/Demo/Portal/XF/Admin/Controller目录下创建一个名为Forum.php的文件。虽然路径看起来很长,但我们推荐这种结构,因为它有助于识别扩展类,因为文件位于与扩展插件ID(如XF)名称相同的目录下。同时,目录结构清晰地表明了扩展了哪个默认类。文件内容暂时应如下所示:

src/addons/Demo/Portal/XF/Admin/Controller/Forum.php
<?php

namespace Demo\Portal\XF\Admin\Controller;

class Forum extends XFCP_Forum
{

}

关于类扩展和类型提示的更多信息,请参阅扩展类类型提示

点击保存以保存类扩展。现在我们可以添加一些代码。我们需要扩展的方法是名为saveTypeData的受保护函数。在扩展任何类中的现有方法时,重要的是检查原始方法。首先,确保我们在扩展方法中使用的参数与被扩展方法的参数匹配。其次,我们需要了解这个方法的实际功能,例如,它是否返回特定类型或对象。在大多数控制器操作中,这是常见的,如我们在修改控制器操作回复(正确方式)部分提到的。然而,尽管这个方法在控制器内,但它本身并不是一个控制器动作。实际上,这是一个无返回值的方法。但我们在扩展方法中始终应调用父方法,先添加扩展方法本身,暂不添加新代码:

src/addons/Demo/Portal/XF/Admin/Controller/Forum.php
protected function saveTypeData(FormAction $form, \XF\Entity\Node $node, \XF\Entity\AbstractNode $data)
{
    parent::saveTypeData($form, $node, $data);
}

注意

该方法的参数列表假设我们有一个use声明,将完整的\XF\Mvc\FormAction类别简写为FormAction。请自行添加这个use声明,将其添加到namespaceclass之间的位置。

目前,我们已扩展了该方法,但我们的扩展在没有做任何额外处理时会被调用。接下来,我们需要从论坛编辑页面获取输入值,并将其应用到$data实体(在这种情况下是论坛实体)。

src/addons/Demo/Portal/XF/Admin/Controller/Forum.php
protected function saveTypeData(FormAction $form, \XF\Entity\Node $node, \XF\Entity\AbstractNode $data)
{
    parent::saveTypeData($form, $node, $data);

    $form->setup(function() use ($data)
    {
        $data->demo_portal_auto_feature = $this->filter('demo_portal_auto_feature', 'bool');
    });
}

使用 FormAction 对象可以让我们在典型的表单提交过程中拥有各种扩展点。它并不是所有控制器操作都可用。例如,在管理控制面板(Admin CP)中更为常见,通常遵循简单的 CRUD 模型(创建、读取、更新、删除)。XF 中的许多其他过程发生在服务对象内部,这些服务对象通常具有与正在运行的服务相关的特定扩展点。FormAction 对象的这种特殊用法与通常遇到的情况有所不同。保存节点是一个稍微不同的过程,因为除了处理节点实体外,还需要处理与之关联的节点类型,例如论坛实体。不过,在这个方法中我们可以访问表单操作对象,因此我们应该使用它。我们在这里使用它在过程的“setup”阶段添加了一个特定的行为。具体来说,当调用 FormAction 对象的 run() 方法时,它将按照特定顺序运行各个阶段。无论这些行为以何种顺序添加到对象中,它们仍将按照 setupvalidateapplycomplete 的顺序运行。

我们上面添加的代码让我们可以将论坛实体中的 demo_portal_auto_feature 列设置为我们在论坛编辑页面上添加的 demo_portal_auto_feature 输入中存储的值。现在应该可以测试这一切是否正常工作了。只需编辑您选择的论坛并勾选复选框。您应该能够观察到两件事。首先,当您重新进入编辑该论坛时,复选框现在应该被勾选。其次,如果您查看刚刚编辑的论坛的 xf_forum 表,demo_portal_auto_feature 字段现在应该设置为 1。请保持此论坛的此值启用,因为我们最终将自动推荐该论坛中的帖子。

自动设置帖子为推荐#

我们已经在论坛实体中添加了一个新列,允许我们在该论坛中创建新帖子时自动推荐该帖子,现在是时候添加实现此功能的代码了。

在 XF2 中,我们大量使用服务对象。这些通常采用“设置并执行”的方法;您设置配置,然后调用一个方法来完成操作。我们使用服务对象来设置和执行帖子创建,因此这是添加我们所需代码的绝佳位置。这一切都始于另一个类扩展,因此请转到“添加类扩展”页面。

这次,基类将是 XF\Service\Thread\Creator,扩展类将是 Demo\Portal\XF\Service\Thread\Creator,和往常一样,这个新类将类似于下面的代码。在路径 src/addons/Demo/Portal/XF/Service/Thread/Creator.php 中创建该代码,然后单击“保存”以创建扩展。

src/addons/Demo/Portal/XF/Service/Thread/Creator.php
<?php

namespace Demo\Portal\XF\Service\Thread;

class Creator extends XFCP_Creator
{

}

在这里,我们还将创建另一个扩展。基类将是 XF\Pub\Controller\Forum,扩展类将是 Demo\Portal\XF\Pub\Controller\Forum。在路径 src/addons/Demo/Portal/XF/Pub/Controller/Forum.php 中创建以下代码,然后单击“保存”:

src/addons/Demo/Portal/XF/Pub/Controller/Forum.php
<?php

namespace Demo\Portal\XF\Pub\Controller;

class Forum extends XFCP_Forum
{

}

我们最终将在扩展的帖子创建者对象中扩展 _save() 方法,以便在创建帖子后将其推荐。为了符合“设置并执行”的方法,我们将创建一个方法,用于指示是否应将帖子创建为推荐帖子。为此,我们需要两件事:一个用于存储值的类属性(默认为 null)和一个允许设置该属性的公共方法。

src/addons/Demo/Portal/XF/Service/Thread/Creator.php
protected $featureThread;

public function setFeatureThread($featureThread)
{
    $this->featureThread = $featureThread;
}

回到我们新扩展的论坛控制器,我们现在将扩展设置创建者服务的方法,并在论坛实体设置了必要值时选择推荐帖子。请记住,在扩展方法之前,我们需要知道它预期返回什么(如果有),并确保我们调用父方法。如果父方法返回某些内容,那么我们应该在我们的代码完成后返回它。在这种情况下,setupThreadCreate() 方法返回设置好的创建者服务,因此我们将从以下内容开始:

src/addons/Demo/Portal/XF/Pub/Controller/Forum.php
protected function setupThreadCreate(\XF\Entity\Forum $forum)
{
    /** @var \Demo\Portal\XF\Service\Thread\Creator $creator */
    $creator = parent::setupThreadCreate($forum);

    return $creator;
}

正如预期的那样,这实际上并没有做任何事情;扩展代码被调用,但它所做的只是返回父调用返回的内容。我们现在应该修改 $creator,以设置推荐(如果适用于我们当前正在处理的论坛)。

$creator 行和 return 行之间添加:

src/addons/Demo/Portal/XF/Pub/Controller/Forum.php
if ($forum->demo_portal_auto_feature)
{
    $creator->setFeatureThread(true);
}

我们现在可以将 _save() 方法添加到扩展的创建者类中:

src/addons/Demo/Portal/XF/Service/Thread/Creator.php
protected function _save()
{
    $thread = parent::_save();

    return $thread;
}

为了确保此帖子被推荐,在 $thread 行和 return 行之间,我们只需添加:

src/addons/Demo/Portal/XF/Service/Thread/Creator.php
if ($this->featureThread && $thread->discussion_state == 'visible')
{
    /** @var \Demo\Portal\Entity\FeaturedThread $featuredThread */
    $featuredThread = $thread->getRelationOrDefault('FeaturedThread');
    $featuredThread->save();

    $thread->fastUpdate('demo_portal_featured', true);
}

因为我们之前在帖子实体上创建了 FeaturedThread 关系,所以我们实际上也可以使用该关系进行创建!这里我们使用了一个名为 getRelationOrDefault() 的方法。这将查看该关系是否实际返回现有记录,如果没有,它将创建实体并设置任何默认值,甚至是帖子 ID!这意味着我们实际上只需要获取默认关系并保存它即可将其插入数据库。

此外,我们应该将 demo_portal_featured 字段设置为 true。因为帖子实体已经保存(当原始类保存实体时),我们可以使用 fastUpdate() 方法快速更新该字段。

我们现在需要尝试这一切并确保它正常工作。转到您之前启用了 demo_portal_auto_feature 选项的论坛,并创建一个新帖子。目前唯一能判断它是否正常工作的方法是检查 xf_demo_portal_featured_thread 表,我们应该在那里看到一条新记录!

创建门户页面#

在我们完成之前,还有很多工作要做,但现在我们已经能够推荐帖子了,如果能在某个地方显示它们,那肯定会很好,所以让我们开始创建我们的门户页面。

为此,我们需要一个新的公共路由。转到管理控制面板,在“开发”下点击“路由”,然后点击“添加路由:公共”。我们现在将保持简单。路由前缀将是“portal”,部分上下文将是“home”,控制器将是“Demo\Portal:Portal”。

我们现在应该在路径 src/addons/Demo/Portal/Pub/Controller/Portal.php 中创建该控制器,并包含以下基本内容:

src/addons/Demo/Portal/Pub/Controller/Portal.php
<?php

namespace Demo\Portal\Pub\Controller;

class Portal extends \XF\Pub\Controller\AbstractController
{

}

我们希望当人们访问 index.php?portal 页面时显示我们的门户。此 URL 没有“action”部分——只有我们刚刚创建的路由前缀。考虑到这一点,我们需要添加的代码应该放在 actionIndex() 方法中。我们在该方法中需要的基本代码是:

src/addons/Demo/Portal/Pub/Controller/Portal.php
public function actionIndex()
{
    $viewParams = [];
    return $this->view('Demo\Portal:View', 'demo_portal_view', $viewParams);
}

现在,这还不会完全工作,因为我们还没有创建模板,但这至少足以证明我们的路由和控制器正在相互通信。因此,访问 index.php?portal 至少应该显示一个“模板错误”。

正如在 View reply 部分中提到的,第一个参数是一个视图类,但我们实际上不需要创建这个类。如果需要,其他插件可以扩展这个类,即使它不存在。第二个参数是模板,我们现在需要在路径 src/addons/Demo/Portal/_output/templates/public/demo_portal_view.html 中创建它。该模板现在应该简单地包含以下内容:

src/addons/Demo/Portal/_output/templates/public/demo_portal_view.html
<xf:title>Portal</xf:title>

如果我们现在访问门户页面,模板错误将消失,尽管我们仍然会看到一个相当空白的页面,但它现在至少会有标题“Portal”。

现在,是时候开始添加显示推荐帖子列表的代码了。第一步是为一些常见的基本查找器查询创建一个存储库。因此,在路径 src/addons/Demo/Portal/Repository/FeaturedThread.php 中创建一个新文件,并添加以下代码:

src/addons/Demo/Portal/Repository/FeaturedThread.php
<?php

namespace Demo\Portal\Repository;

use XF\Mvc\Entity\Finder;
use XF\Mvc\Entity\Repository;

class FeaturedThread extends Repository
{
    /**
     * @return Finder
     */
    public function findFeaturedThreadsForPortalView()
    {
        $visitor = \XF::visitor();

        $finder = $this->finder('Demo\Portal:FeaturedThread');
        $finder
            ->setDefaultOrder('featured_date', 'DESC')
            ->with('Thread', true)
            ->with('Thread.User')
            ->with('Thread.Forum', true)
            ->with('Thread.Forum.Node.Permissions|' . $visitor->permission_combination_id)
            ->with('Thread.FirstPost', true)
            ->with('Thread.FirstPost.User')
            ->where('Thread.discussion_type', '<>', 'redirect')
            ->where('Thread.discussion_state', 'visible');

        return $finder;
    }
}

我们在这里所做的是使用查找器查询所有推荐帖子,按 featured_date 降序排列,并连接到 xf_thread 表,并从该表连接到 xf_user 表以获取帖子创建者,xf_forum 表,xf_post 表,并从那里再次连接到 xf_user 表以获取帖子创建者。我们通过为这些参数指定 true 来断言帖子、论坛和第一篇帖子必须存在,因此这些将作为 INNER JOIN 执行,而用户查询将使用 LEFT JOIN 执行。某些帖子和帖子的作者可能不存在(例如,如果它们是由 RSS 订阅系统自动发布的,或者是由访客发布的)。

我们这里还有一个特殊的连接,它获取当前访问者的权限以及查询。这将减少渲染门户页面所需的查询数量,因为我们稍后将做一些事情,只向有权查看它们的用户显示推荐帖子。

这不会返回此查询的结果。它返回查找器对象本身。这提供了一个清晰的扩展点,以防另一个插件需要扩展我们的代码,并且还允许我们在获取数据之前进行进一步的更改(例如设置分页的限制/偏移量,或设置不同的顺序)。

现在让我们在门户控制器的 actionIndex() 方法中使用它。将现有的 $viewParams = []; 行更改为以下内容:

src/addons/Demo/Portal/Pub/Controller/Portal.php
/** @var \Demo\Portal\Repository\FeaturedThread $repo */
$repo = $this->repository('Demo\Portal:FeaturedThread');

$finder = $repo->findFeaturedThreadsForPortalView();

$viewParams = [
    'featuredThreads' => $finder->fetch()
];

在这个阶段,我们暂时不打算修改从存储库中获取的基础查找器。相反,让我们开始实际查看一些结果,并更新 demo_portal_view 模板,如下所示(在 <xf:title> 标签之后):

src/addons/Demo/Portal/_output/templates/public/demo_portal_view.html
<xf:if is="$featuredThreads is not empty">
    <xf:foreach loop="$featuredThreads" value="$featuredThread">
        <xf:macro name="thread_block"
            arg-thread="{$featuredThread.Thread}"
            arg-post="{$featuredThread.Thread.FirstPost}"
            arg-featuredThread="{$featuredThread}"
        />
    </xf:foreach>
<xf:else />
    <div class="blockMessage">还没有推荐的帖子。</div>
</xf:if>

<xf:macro name="thread_block" arg-thread="!" arg-post="!" arg-featuredThread="!">
    <xf:css src="message.less" />

    <div class="block">
        <div class="block-container" data-xf-init="lightbox">
            <h4 class="block-header"><a href="{{ link('threads', $thread) }}">{$thread.title}</a></h4>
            <div class="block-body">
                <xf:macro name="message"
                    arg-post="{$post}"
                    arg-thread="{$thread}"
                    arg-featuredThread="{$featuredThread}"
                />
            </div>
            <div class="block-footer">
                <a href="{{ link('threads', $thread) }}">继续阅读...</a>
            </div>
        </div>
    </div>
</xf:macro>

<xf:macro name="message" arg-post="!" arg-thread="!" arg-featuredThread="!">
    <div class="message message--post message--simple">
        <div class="message-inner">
            <div class="message-cell message-cell--main">
                <div class="message-content js-messageContent">
                    <div class="message-attribution">
                        <div class="contentRow contentRow--alignMiddle">
                            <div class="contentRow-figure">
                                <xf:avatar user="{$post.User}" size="xxs" defaultname="{$post.username}" href="" />
                            </div>
                            <div class="contentRow-main contentRow-main--close">
                                <ul class="listInline listInline--bullet u-muted">
                                    <li><xf:username user="{$thread.User}" /></li>
                                    <li><xf:date time="{$featuredThread.featured_date}" /></li>
                                    <li><a href="{{ link('forums', $thread.Forum) }}">{$thread.Forum.title}</a></li>
                                    <li>{{ phrase('replies:') }} {$thread.reply_count|number}</li>
                                </ul>
                            </div>
                        </div>
                    </div>
                    <div class="message-userContent lbContainer js-lbContainer"
                         data-lb-id="post-{$post.post_id}"
                         data-lb-caption-desc="{{ $post.User ? $post.User.username : $post.username }} &middot; {{ date_time($post.post_date) }}"
                    >
                        <blockquote class="message-body">
                            {{ bb_code($post.message, 'post', $post.User, {
                                'attachments': $post.attach_count ? $post.Attachments : [],
                                'viewAttachments': $thread.canViewAttachments()
                            }) }}
                        </blockquote>
                    </div>
                </div>
            </div>
        </div>
    </div>
</xf:macro>

现在,不可否认,这里有很多内容。虽然看起来可能有些吓人,但它主要是为了以合理的样式显示我们推荐的帖子。不过,有几件事值得注意。

我们从模板开始,使用一个条件 <xf:if is="$featuredThreads is not empty">。这是为了检查查找器返回的对象是否实际包含推荐的帖子记录。如果没有,则显示适当的消息。

如果我们有一些记录,我们需要循环遍历每个记录以显示它。对于每个记录,我们调用一个 macro。宏是可重用的模板代码部分,它们是自文档化的(因为您可以看到支持哪些参数)并且维护自己的作用域,不会被调用宏的模板中的参数污染;这意味着宏只能访问显式传递的参数和全局的 $xf 参数。

thread_block 宏显示推荐帖子的基本块,然后调用另一个宏来显示每条消息。

实现导航选项卡#

您可能已经注意到,在设置路由时,我们将部分上下文指定为“home”,当您访问门户页面时,主页选项卡被选中,或者如果选项中没有设置 homePageUrl,您可能根本没有看到主页选项卡。我们希望使用默认的主页选项卡,而不是自己创建一个并可能导致重复的选项卡。

为此,我们应该使用代码事件监听器将 URL 更改为我们的门户 URL。在管理控制面板中,点击“开发”下的“代码事件监听器”,然后点击“添加代码事件监听器”。监听事件 home_page_url,回调类将再次是 Demo\Portal\Listener,这次方法将命名为 homePageUrl

这个新方法的代码应该相当简单:

src/addons/Demo/Portal/Listener.php
public static function homePageUrl(&$homePageUrl, \XF\Mvc\Router $router)
{
    $homePageUrl = $router->buildLink('canonical:portal');
}

最后,我们应该考虑将索引页面的路由更改为我们的门户页面。转到管理控制面板,点击“设置”下的“选项”,然后点击“基本论坛信息”。将“索引页面路由”选项更改为 portal/

当您在管理控制面板中时,让我们看看现在点击标题中的论坛标题会发生什么。这应该会将您带到您的索引页面。如果一切顺利,该索引页面现在应该是您的门户!此外,主页选项卡应该是可见的,并且被选中。

作为可选步骤,您可以选择在主页选项卡下添加一些额外的导航条目。但现在,让我们继续。

手动推荐(或取消推荐)帖子#

所以,我们可以自动推荐新帖子。那么手动推荐现有帖子呢?或者在)创建帖子时手动推荐帖子,而自动推荐不受支持?这将是一个很好的方法,让我们当前的门户页面看起来更繁忙。

为了实现这一点,我们将向特定的宏添加一个模板修改,这个宏实际上在帖子回复、帖子编辑和创建帖子时使用。这将涉及扩展编辑器服务并修改处理自动推荐的现有代码。

第一步是新的模板修改。因此,转到“添加模板修改”(确保在“模板修改”列表中选择了“公共”选项卡)。这次我们修改的模板是 helper_thread_options,我们将使用 demo_portal_helper_thread_options 作为键,您可以编写一个合理的描述。我们实际上可以在这里进行“简单替换”,因此保持该单选按钮选中,并在“查找”字段中添加:

查找
<xf:if is="$thread.canLockUnlock()">

在“替换”字段中添加:

替换
<xf:if is="($thread.isInsert() AND !$thread.Forum.demo_portal_auto_feature AND $thread.canFeatureUnfeature())
    OR ($thread.isUpdate() && $thread.canFeatureUnfeature())"
>
    <xf:option label="{{ phrase('demo_portal_featured') }}" name="featured" value="1" selected="{$thread.demo_portal_featured}">
        <xf:hint>{{ phrase('demo_portal_featured_hint') }}</xf:hint>
        <xf:afterhtml>
            <xf:hiddenval name="_xfSet[featured]" value="1" />
        </xf:afterhtml>
    </xf:option>
</xf:if>
$0

这个条件有点长,但它允许我们在两种特定条件下显示推荐复选框:a) 如果帖子尚未创建且论坛的自动推荐选项已禁用,并且有权限推荐帖子;或者 b) 它是一个现有帖子,并且有权限推荐/取消推荐。

快速“测试”应该显示此附加代码将插入到现有 <xf:checkboxrow> 中的“打开”复选框上方。如果一切看起来都很好,点击“保存”。

我们不得不直接在修改中使用模板代码,因为以这种方式包含模板(就像我们之前所做的那样)在现有的输入或行标签中不起作用。我们现在还需要为标签和提示创建短语,因为以后无法检测到这些短语。

在“外观”下,转到“短语”并点击“添加短语”。确保选择了您的插件。第一个短语的“标题”将是 demo_portal_featured,文本将简单地是“推荐”。点击“保存并退出”。再次点击“添加短语”。第二个短语的“标题”将是 demo_portal_featured_hint,文本将是“推荐的帖子将出现在门户页面上。”

回到我们刚刚添加到修改中的模板代码;您可能已经注意到了一些东西。我们在帖子实体上调用了 canFeatureUnfeature() 方法,而该方法尚不存在。我们最终将使用它来进行权限检查,以控制用户是否可以手动推荐帖子。

要添加此方法,我们需要为 XF\Entity\Thread 实体创建一个新的类扩展。因此,现在像我们之前所做的那样进行。扩展类将是 Demo\Portal\XF\Entity\Thread,因此在路径 src/addons/Demo/Portal/XF/Entity/Thread.php 中创建它,内容如下:

src/addons/Demo/Portal/XF/Entity/Thread.php
<?php

namespace Demo\Portal\XF\Entity;

class Thread extends XFCP_Thread
{
    public function canFeatureUnfeature()
    {
        return true;
    }
}

好的,到目前为止,我们还没有做太多有价值的事情。canFeatureUnfeature() 方法现在所做的只是返回 true。稍后,我们将实现一些适当的权限并将它们添加到这里。

为了测试这一点是否有效,打开您之前推荐的其中一个帖子,并从工具菜单中选择“编辑帖子”。我们应该看到“设置帖子状态”复选框行中有我们添加的“推荐”复选框,并且它应该被选中,表示该帖子确实被推荐了。

我们现在可以继续更改帖子编辑器服务,以查找此值并相应地推荐或取消推荐。为此,我们需要两个新的类扩展。回到“添加类扩展”页面。第一个将具有基类 XF\Pub\Controller\Thread 和扩展类 Demo\Portal\XF\Pub\Controller\Thread。第二个将具有基类 XF\Service\Thread\Editor 和扩展类 Demo\Portal\XF\Service\Thread\Editor

编辑器服务实际上与我们之前创建的扩展创建者服务非常相似,因此在相关位置创建它。以下是扩展类的所有代码:

src/addons/Demo/Portal/XF/Service/Thread/Editor.php
<?php

namespace Demo\Portal\XF\Service\Thread;

class Editor extends XFCP_Editor
{
    protected $featureThread;

    public function setFeatureThread($featureThread)
    {
        $this->featureThread = $featureThread;
    }

    protected function _save()
    {
        $thread = parent::_save();

        if ($this->featureThread !== null && $thread->discussion_state == 'visible')
        {
            /** @var \Demo\Portal\Entity\FeaturedThread $featuredThread */
            $featuredThread = $thread->getRelationOrDefault('FeaturedThread', false);

            if ($this->featureThread)
            {
                if (!$featuredThread->exists())
                {
                    $featuredThread->save();
                    $thread->fastUpdate('demo_portal_featured', true);
                }
            }
            else
            {
                if ($featuredThread->exists())
                {
                    $featuredThread->delete();
                    $thread->fastUpdate('demo_portal_featured', false);
                }
            }
        }

        return $thread;
    }
}

这段代码比创建服务中的代码稍微复杂一些。例如,可能存在用户编辑帖子但没有权限编辑的情况,因此我们不会显示复选框。在这种情况下,我们不希望自动假设帖子应该被取消推荐。由于类属性 $featureThread 默认为 null,我们可以利用这一点,使该属性本质上具有三种状态。在这种情况下,null 表示“无更改”,true 表示我们推荐该帖子,false 表示我们取消推荐。

在取消推荐的情况下,我们实际上只是通过调用 delete() 方法来删除推荐帖子实体。在这两种情况下,我们再次使用 fastUpdate() 方法来更新帖子实体中的缓存值,以反映当前的推荐状态。

在完成编辑过程之前,我们需要在扩展的帖子控制器中添加代码,特别是扩展 setupThreadEdit() 方法。整个扩展的帖子控制器代码如下:

src/addons/Demo/Portal/XF/Pub/Controller/Thread.php
<?php

namespace Demo\Portal\XF\Pub\Controller;

class Thread extends XFCP_Thread
{
    public function setupThreadEdit(\XF\Entity\Thread $thread)
    {
        /** @var \Demo\Portal\XF\Service\Thread\Editor $editor */
        $editor = parent::setupThreadEdit($thread);

        $canFeatureUnfeature = $thread->canFeatureUnfeature();
        if ($canFeatureUnfeature)
        {
            $editor->setFeatureThread($this->filter('featured', 'bool'));
        }

        return $editor;
    }
}

这应该足以编辑帖子并将其状态设置为推荐(或取消推荐)。如果你现在尝试这个功能,你应该能够看到帖子在你的门户页面上相应地出现和消失。

我们需要在帖子控制器中扩展另一个方法,以处理在某些帖子回复表单上也显示帖子状态控件的情况。

我们只需要在上面添加的 setupThreadEdit() 方法下面添加以下代码:

src/addons/Demo/Portal/XF/Pub/Controller/Thread.php
public function finalizeThreadReply(\XF\Service\Thread\Replier $replier)
{
    parent::finalizeThreadReply($replier);

    $setOptions = $this->filter('_xfSet', 'array-bool');
    if ($setOptions)
    {
        $thread = $replier->getThread();

        if ($thread->canFeatureUnfeature() && isset($setOptions['featured']))
        {
            $replier->setFeatureThread($this->filter('featured', 'bool'));
        }
    }
}

请注意,我们在这个方法中没有返回任何内容,因为它不需要返回任何内容。

对于手动推荐/取消推荐帖子的最后一步,我们需要回到论坛控制器,并稍微修改我们现有的代码,以便在推荐不是自动的情况下,我们可以手动处理。这应该相当简单。进入你扩展的论坛控制器,并替换以下代码:

src/addons/Demo/Portal/XF/Pub/Controller/Thread.php
if ($forum->demo_portal_auto_feature)
{
    $creator->setFeatureThread(true);
}

替换为:

src/addons/Demo/Portal/XF/Pub/Controller/Thread.php
if ($forum->demo_portal_auto_feature)
{
    $creator->setFeatureThread(true);
}
else
{
    $setOptions = $this->filter('_xfSet', 'array-bool');
    if ($setOptions)
    {
        $thread = $creator->getThread();

        if ($thread->canFeatureUnfeature() && isset($setOptions['featured']))
        {
            $creator->setFeatureThread($this->filter('featured', 'bool'));
        }
    }
}

这与我们已有的代码基本相同,例如,如果论坛启用了自动推荐功能,那么我们只需将帖子设置为推荐,否则,我们检查复选框是否可用,并像其他情况一样,将其设置为复选框的状态。

我们现在应该测试创建3个帖子,以确保其按预期工作。第一个在启用了自动推荐的论坛中,以确保其仍然有效,然后在未启用自动推荐的论坛中,选中“推荐”复选框,再次测试未选中复选框的情况。假设这些都有效,让我们继续。

改进门户页面#

门户页面看起来还不错,但我们可以做得更好。

首先,我们应该调整代码,使其只显示X个推荐帖子,并且还应该添加一些页面导航。此时,如果你还没有这样做,可能值得推荐更多的帖子,以便我们实际测试分页功能!

首先,我们需要回到门户控制器,并在 actionIndex() 方法的顶部添加一些代码:

src/addons/Demo/Portal/Pub/Controller/Portal.php
$page = $this->filterPage();
$perPage = 5;

第一行是一个特殊的辅助方法,用于获取当前页码。第二行是我们每页要加载的项目数。这通常来自一个选项,但我们现在将其硬编码为5。

接下来要做的是将这一行:

src/addons/Demo/Portal/Pub/Controller/Portal.php
$finder = $repo->findFeaturedThreadsForPortalView();

改为:

src/addons/Demo/Portal/Pub/Controller/Portal.php
$finder = $repo->findFeaturedThreadsForPortalView()
    ->limitByPage($page, $perPage);

这将更改我们的查询,使其根据上面定义的页面/每页值进行限制。这将自动计算当前页面的正确限制($perPage)和偏移量(($page - 1) * $perPage)。接下来,我们需要将更多参数传递到我们的视图参数中,因此将:

src/addons/Demo/Portal/Pub/Controller/Portal.php
$viewParams = [
    'featuredThreads' => $finder->fetch()
];

改为:

src/addons/Demo/Portal/Pub/Controller/Portal.php
$viewParams = [
    'featuredThreads' => $finder->fetch(),
    'total' => $finder->total(),
    'page' => $page,
    'perPage' => $perPage
];

为了显示我们的页面导航,我们需要知道总条目数,我们可以使用 total() 方法从查找器中获取,当前页码以及每页显示的数量。

如果你回到门户页面,你现在只会看到5个推荐帖子。然而,我们现在需要添加页面导航。因此,打开 demo_portal_view 模板,并在 </xf:foreach> 标签之后直接添加以下内容:

src/addons/Demo/Portal/_output/templates/public/demo_portal_view.html
<xf:pagenav page="{$page}" perpage="{$perPage}" total="{$total}" link="portal" wrapperclass="block" />

此时重新加载门户页面,只要你有多于5个推荐帖子,你现在会在推荐帖子列表的底部看到页面导航。

另一个可能有助于改善页面外观的功能是添加一个侧边栏,或者更准确地说,是一个显示在侧边栏的小部件位置。

小部件位置可以在管理面板的“开发”部分添加。转到“小部件位置”页面,然后点击“添加小部件位置”。输入一个“位置ID”为 demo_portal_view_sidebar,一个“标题”为 Demo portal view: Sidebar 和一个适当的描述。确保位置已启用,并选择了正确的附加组件ID后,点击“保存”。

要将此位置添加到模板中,只需在 <xf:title> 标签下方添加以下内容:

src/addons/Demo/Portal/_output/templates/public/demo_portal_view.html
<xf:widgetpos id="demo_portal_view_sidebar" position="sidebar" />

当然,在我们添加一些小部件之前,我们仍然不会看到侧边栏。小部件本身并不分配给附加组件,因此如果你希望默认提供一些配置好的小部件,你需要将这些小部件添加到安装类中。

为了简单起见,我们将复制当前分配给 forum_list_sidebar 位置的小部件(默认情况下)。因此,我们将把这些小部件添加到安装类的一个新的 installStep4() 方法中:

src/addons/Demo/Portal/Setup.php
public function installStep4()
{
    $this->createWidget('demo_portal_view_members_online', 'members_online', [
        'positions' => ['demo_portal_view_sidebar' => 10]
    ]);

    $this->createWidget('demo_portal_view_new_posts', 'new_posts', [
        'positions' => ['demo_portal_view_sidebar' => 20]
    ]);

    $this->createWidget('demo_portal_view_new_profile_posts', 'new_profile_posts', [
        'positions' => ['demo_portal_view_sidebar' => 30]
    ]);

    $this->createWidget('demo_portal_view_forum_statistics', 'forum_statistics', [
        'positions' => ['demo_portal_view_sidebar' => 40]
    ]);

    $this->createWidget('demo_portal_view_share_page', 'share_page', [
        'positions' => ['demo_portal_view_sidebar' => 50]
    ]);
}

当然,别忘了为你自己运行这个安装步骤:

Terminal
php cmd.php xf-addon:install-step Demo/Portal 4

实现权限和优化#

目前,我们在门户中显示所有推荐的帖子,无论访问者是否有权限查看它们。这并不理想;可能存在某些情况下,你希望推荐来自某些受限论坛的帖子,并且只有那些通常可以查看该论坛的用户才能看到这些帖子。

为了实现这一点,我们需要更改我们的代码,以便“过度获取”我们需要显示的记录数量,过滤掉任何不可查看的结果,然后将结果集合切片到我们实际想要显示的每页数量。这比听起来要简单一些。

首先,转到门户控制器,并将这一行:

src/addons/Demo/Portal/Pub/Controller/Portal.php
->limitByPage($page, $perPage);

改为:

src/addons/Demo/Portal/Pub/Controller/Portal.php
->limit($perPage * 3);

并在下面添加:

src/addons/Demo/Portal/Pub/Controller/Portal.php
$featuredThreads = $finder->fetch()
    ->filter(function(\Demo\Portal\Entity\FeaturedThread $featuredThread)
    {
        return ($featuredThread->Thread->canView());
    })
    ->sliceToPage($page, $perPage);

最后将:

src/addons/Demo/Portal/Pub/Controller/Portal.php
'featuredThreads' => $finder->fetch(),

改为:

src/addons/Demo/Portal/Pub/Controller/Portal.php
'featuredThreads' => $featuredThreads,

你可能早先在 demo_portal_view 模板中注意到,我们渲染的每个帖子还指定了它的附件:

src/addons/Demo/Portal/_output/templates/public/demo_portal_view.html
'attachments': $post.attach_count ? $post.Attachments : [],

目前,这将为每个帖子生成一个额外的查询。因此,我们应该尝试为我们显示的所有帖子进行一次查询,并提前将它们添加到帖子中。这可能听起来比实际更复杂。只需在 ->slice(0, $perPage, true); 行下面添加以下代码。

src/addons/Demo/Portal/Pub/Controller/Portal.php
$threads = $featuredThreads->pluckNamed('Thread');
$posts = $threads->pluckNamed('FirstPost', 'first_post_id');

/** @var \XF\Repository\Attachment $attachRepo */
$attachRepo = $this->repository('XF:Attachment');
$attachRepo->addAttachmentsToContent($posts, 'post');

我们首先使用 pluckNamed() 方法获取一个帖子集合,然后再次使用它从帖子中获取一个帖子集合(按帖子ID键控)。一旦我们有了帖子,我们就可以将它们传递到附件仓库中的一个特殊方法中,该方法执行一次查询并“填充”每个帖子的附件关系。

最后,与权限相关的最后一步是创建一个新的权限,以控制谁可以手动推荐/取消推荐帖子。为此,在管理面板的“开发”部分点击“权限定义”,然后点击“添加权限”。“权限组”将是 forum,“权限ID”将是 demoPortalFeature,“标题”应为 Can feature / unfeature threads,设置“界面组”为 Forum moderator permissions,并选择适当的显示顺序并确保选择了你的附加组件后,点击“保存”。

要实际使用此权限,我们需要回到我们扩展的帖子实体,修改 canFeatureUnfeature() 方法。将 return true; 替换为:

src/addons/Demo/Portal/XF/Entity/Thread.php
return \XF::visitor()->hasNodePermission($this->node_id, 'demoPortalFeature');

此时,由于权限没有任何默认值,如果你去编辑任何帖子,你会发现“推荐”复选框不见了。但是,如果你给自己授予该权限,复选框将会重新出现。因此,这应该证明权限按预期工作!

创建一些选项#

我们目前每页只显示5个推荐帖子,但能够选择显示更多帖子会更好。创建选项很容易。虽然不是必需的,但我们首先创建一个新的选项组,然后在该组中添加一个新选项。

在管理面板的“设置”下的“选项”中,点击“添加选项组”按钮。我们将“组ID”命名为 demoPortal,并给它一个标题“Demo - Portal options”。给它一个适当的“描述”和“显示顺序”,然后点击“保存”。

现在点击“添加选项”。设置“选项ID”为 demoPortalFeaturedPerPage,“标题”为 Featured threads per page,编辑格式为 Spin box,“数据类型”为 Positive integer,“默认值”为 10。点击“保存”。

要实现这一点,回到门户控制器,并将:

src/addons/Demo/Portal/Pub/Controller/Portal.php
$perPage = 5;

改为:

src/addons/Demo/Portal/Pub/Controller/Portal.php
$perPage = $this->options()->demoPortalFeaturedPerPage;

添加另一个选项可能不会有坏处。也许另一个有用的选项是能够将默认排序顺序从 xf_demo_portal_featured_thread.feartured_date 更改为 xf_thread.post_date。回到“Demo - Portal options”组,点击“添加选项”。

设置“选项ID”为 demoPortalDefaultSort,“标题”为 Default sort order,“编辑格式”为 Radio buttons。对于“格式参数”,设置如下:

Format parameters
featured_date={{ phrase('demo_portal_featured_date') }}
post_date={{ phrase('demo_portal_post_date') }}

最后设置“默认值”为 featured_date,然后点击“保存”。

我们需要为单选按钮标签创建短语,类似于我们之前为模板修改创建的短语。

将选项值设置为“Post date”。

严格来说,我们可以直接更新我们的仓库方法以使用新选项,但可能值得看看自定义查找器方法的工作原理。在路径 src/addons/Demo/Portal/Finder/FeaturedThread.php 中创建一个新文件,内容如下:

src/addons/Demo/Portal/Finder/FeaturedThread.php
<?php

namespace Demo\Portal\Finder;

use XF\Mvc\Entity\Finder;

class FeaturedThread extends Finder
{
    public function applyFeaturedOrder($direction = 'ASC')
    {
        $options = \XF::options();

        if ($options->demoPortalDefaultSort == 'featured_date')
        {
            $this->setDefaultOrder('featured_date', $direction);
        }
        else
        {
            $this->setDefaultOrder('Thread.post_date', $direction);
        }

        return $this;
    }
}

正如你所看到的,我们在这里所做的只是创建一个相当基础的类,它扩展了XF的 Finder 对象,并创建了一个简单的方法,该方法查看我们选项的值,并应用适当的默认排序。我们现在可以更新我们的仓库方法以使用它。

在我们的推荐帖子仓库中,找到:

src/addons/Demo/Portal/Repository/FeaturedThread.php
->setDefaultOrder('featured_date', 'DESC')

并将其改为:

src/addons/Demo/Portal/Repository/FeaturedThread.php
->applyFeaturedOrder('DESC')

最后,更新我们的门户视图以显示适当的时间戳可能是有意义的——根据我们的选项值,显示推荐日期或发布日期。

demo_portal_view 模板中,将:

src/addons/Demo/Portal/_output/templates/public/demo_portal_view.html
<li><xf:date time="{$featuredThread.featured_date}" /></li>

改为:

src/addons/Demo/Portal/_output/templates/public/demo_portal_view.html
<li>
    <xf:if is="$xf.options.demoPortalDefaultSort == 'featured_date'">
        <xf:date time="{$featuredThread.featured_date}" />
    <xf:else />
        <xf:date time="{$thread.post_date}" />
    </xf:if>
</li>

在可见性更改时取消推荐#

为了实现这一点,我们需要再次修改 Thread 实体,但这次我们将使用 entity_post_save 事件。正如我们在实体生命周期中提到的,_postSave() 方法是在实体插入或更新后执行操作的地方。最初,我们将在帖子不再可见时取消推荐它。

因此,回到“添加代码事件监听器”页面,这次监听 entity_post_save 事件。这次的事件提示将是 XF\Entity\Thread。对于执行回调,我们将使用之前使用的相同类(Demo\Portal\Listener),但我们将在这里添加一个名为 threadEntityPostSave 的新方法。让我们现在添加这个方法,以便在保存监听器时它已经存在:

src/addons/Demo/Portal/Listener.php
public static function threadEntityPostSave(\XF\Mvc\Entity\Entity $entity)
{

}

点击“保存”以保存监听器。

这个函数的内容相当简单,让我们来看一下:

src/addons/Demo/Portal/Listener.php
if ($entity->isUpdate())
{
    $visibilityChange = $entity->isStateChanged('discussion_state', 'visible');
    if ($visibilityChange == 'leave')
    {
        $featuredThread = $entity->FeaturedThread;
        if ($featuredThread)
        {
            $featuredThread->delete();
            $entity->fastUpdate('demo_portal_featured', false);
        }
    }
}

我们之前已经取消推荐过帖子,但这次我们希望根据帖子的状态来条件性地执行此操作。我们可以使用 isStateChanged 方法检测状态变化。对于传入的列名和值,此方法将返回 enterleave。例如,如果 discussion_statevisible 更改为 deleted,则在上面的示例中,该方法将返回 leave

一旦我们检测到我们正在“离开”可见状态,我们就可以确保我们有一个推荐帖子关系,并删除它,并更新缓存的值。

这只会覆盖帖子被软删除或发送到审核队列的情况。我们还需要覆盖帖子被永久删除的情况。

为此,我们需要另一个监听器,这次是 entity_post_delete 事件。因此,使用相同的回调类添加它,这次的方法名称为 threadEntityPostDelete。将以下代码添加到监听器类中:

src/addons/Demo/Portal/Listener.php
public static function threadEntityPostDelete(\XF\Mvc\Entity\Entity $entity)
{
    $featuredThread = $entity->FeaturedThread;
    if ($featuredThread)
    {
        $featuredThread->delete();
    }
}

点击“保存”以保存监听器后,值得对此进行测试。为了测试这一点,你可能最好密切关注 xf_demo_portal_featured_thread 表,因为到目前为止,代码已经不会显示不可见的帖子,但始终重要的是不要留下孤立的数据。一切顺利的话,我们几乎完成了...

一些最后的收尾工作#

说到孤立数据,我们应该在卸载附加组件时清理数据库。我们可以在之前创建的 Setup 类中执行此操作。

我们将创建3个新方法,对应于我们的前3个安装步骤:

src/addons/Demo/Portal/Setup.php
public function uninstallStep1()
{
    $this->schemaManager()->alterTable('xf_forum', function(Alter $table)
    {
        $table->dropColumns('demo_portal_auto_feature');
    });
}

public function uninstallStep2()
{
    $this->schemaManager()->alterTable('xf_thread', function(Alter $table)
    {
        $table->dropColumns('demo_portal_featured');
    });
}

public function uninstallStep3()
{
    $this->schemaManager()->dropTable('xf_demo_portal_featured_thread');
}

我们不必创建一个卸载步骤来删除小部件,因为它们将在删除小部件位置时自动删除。对于我们创建并关联到附加组件的任何其他数据也是如此——它们将在卸载时自动删除。

构建附加组件#

任何附加组件的最后一步都是发布它!这涉及从数据库中提取XML文件(这些文件包含在包中并用于安装),计算每个文件的哈希值并将其添加到我们的 hashes.json 中,并将相关文件打包到一个ZIP文件中。

幸运的是,这可以通过一个CLI命令完成!只需执行以下命令:

Terminal
php cmd.php xf-addon:build-release Demo/Portal

终端输出

执行附加组件导出。

将Demo - Portal的数据导出到../src/addons/Demo/Portal/_data。

10/10 [============================] 100%

成功写入。

构建发布ZIP。

将发布ZIP写入../src/addons/Demo/Portal/_releases。

发布成功写入。

因此,到此为止,我们的演示附加组件就完成了!如果你想下载使用上述命令构建的附加组件的源代码,请点击这里:Demo-Portal-1.0.0 Alpha.zip