原生 XenForo 缺失了一个优秀 WEB 社区所需的、成熟成就系统的几大特性:成就图标、分类化,以及隐藏成就。

此文档是一个面向开发人员的示例,演示如何为成就添加分类拓扑,以及向现有的数据表中简单地插入几个新字段,以及编写相关业务逻辑。

分类化​

从业务角度分析,成就的分类应当属于最简单的一种,它们的任务只有一个,那就是归拢特定的一群分类实体。鉴于原生 XenForo 系统已经存在另一种任务完全与之相同的分类——表情分类,故这里直接照搬后者的所有代码与逻辑即可。

数据库表​

新增数据库表xf_trophy_category,且表结构如下:

字段名称类型长度特征描述
trophy_category_idINT10无符号、非空、主键、自增成就分类的主键 ID
display_orderINT10无符号显示顺序

原有数据库表xf_trophy新增字段:

字段名称类型长度特征描述
trophy_category_idINT10无符号、非空成就分类的 ID

实体​

在源代码目录根XF/Entity下新增TrophyCategory.php,抄SmileyCategory.php作业即可:

PHP
<?php

namespace XF\Entity;

use XF;
use XF\Phrase;
use XF\Mvc\Entity\Entity;
use XF\Mvc\Entity\Structure;
use XF\Mvc\Entity\AbstractCollection;

/**
 * COLUMNS
 * @property int|null $trophy_category_id
 * @property int $display_order
 *
 * GETTERS
 * @property-read Phrase $title
 *
 * RELATIONS
 * @property-read AbstractCollection<Trophy> $trophies
 * @property-read \XF\Entity\Phrase|null $MasterTitle
 */
class TrophyCategory extends Entity {
    /** @return Phrase */
    public function getTitle() {
        return XF::phrase($this->getPhraseName());
    }

    public function getPhraseName() {
        if ($this->trophy_category_id === 0) {
            return 'trophy_category_title.uncategorized';
        } else {
            return 'trophy_category_title.' . $this->trophy_category_id;
        }
    }

    public function getMasterPhrase() {
        $phrase = $this->MasterTitle;

        if (!$phrase) {
            $phrase = $this->_em->create(\XF\Entity\Phrase::class);
            $phrase->title = $this->_getDeferredValue(function () {
                return $this->getPhraseName();
            }, 'save');
            $phrase->language_id = 0;
            $phrase->addon_id = '';
        }

        return $phrase;
    }

    protected function _postDelete() {
        if ($this->MasterTitle) {
            $this->MasterTitle->delete();
        }

        $this->db()->update(
            'xf_trophy',
            ['trophy_category_id' => 0],
            'trophy_category_id = ?',
            $this->trophy_category_id
        );
    }

    public static function getStructure(Structure $structure) {
        $structure->table        = 'xf_trophy_category';
        $structure->shortName    = 'XF:TrophyCategory';
        $structure->primaryKey    = 'trophy_category_id';
        $structure->columns = [
            'trophy_category_id'    => [
                'type'            => self::UINT,
                'autoIncrement'    => true,
                'nullable'        => true,
                'unique'        => 'trophy_category_ids_must_be_unique',
            ],
            'display_order'            => ['type' => Entity::UINT, 'default' => 0]
        ];
        $structure->getters        = [
            'title' => true,
        ];
        $structure->relations    = [
            'Trophy' => [
                'entity'        => 'XF:Trophy',
                'type'            => self::TO_MANY,
                'conditions'    => [
                    ['trophy_category_id', '=', '$trophy_category_id'],
                ],
            ],
            'MasterTitle' => [
                'entity'     => 'XF:Phrase',
                'type'       => self::TO_ONE,
                'conditions' => [
                    ['language_id', '=', 0],
                    ['title', '=', 'trophy_category_title.', '$trophy_category_id'],
                ],
            ],
        ];

        return $structure;
    }
}

可以观察到 XenForo 没有考虑为这种最简单级别的分类分配自有标题与描述,而是将其托管给了MasterPhrase。因此,当某个 ID 的分类被创建时,它的标题与描述实际上是通过新增一组名为trophy_category_title.ID的短语来实现存储,以简化数据库结构。

在原有实体Trophy.php中新增成就分类的结构声明与业务逻辑:

PHP
$structure->columns = [
    'trophy_id'             => [ 'type' => self::UINT, 'autoIncrement' => true, 'nullable' => true ],
    'trophy_points'         => [ 'type' => self::UINT, 'required' => true ],
    // 新增成就分类 ID
    'trophy_category_id'    => [ 'type' => self::UINT ],
    'user_criteria'            => [
        'type'     => self::JSON_ARRAY, 'default' => [],
        'required' => 'please_select_criteria_that_must_be_met',
    ],
];

分类有效性校验逻辑仍旧照搬表情分类。值得一提的是,实体中所有以verify*声明的方法都不会在代码中被显式地调用。这些方法会在尝试保存实体时,严格地按照驼峰命名拆分为对应的字段,然后自动执行校验。

PHP
protected function verifyTrophyCategoryId(&$trophyCategoryId) {
    if ($trophyCategoryId > 0) {
        $trophyCategory = $this->_em->find(TrophyCategory::class, $trophyCategoryId);

        if (!$trophyCategory) {
            $this->error(XF::phrase('please_enter_valid_trophy_category_id'), 'trophy_category_id');
            return false;
        }
    }

    return true;
}

以及在实体文件的 IDE 类型提示注释中添加与分类 ID 相关的声明:

PHP
/**
 * COLUMNS
 * @property int|null    $trophy_id
 * @property int        $trophy_points
 * @property int        $trophy_category_id
 * @property array        $user_criteria
 *
 * GETTERS
 * @property-read Phrase $title
 * @property-read Phrase $description
 *
 * RELATIONS
 * @property-read \XF\Entity\Phrase|null $MasterTitle
 * @property-read \XF\Entity\Phrase|null $MasterDescription
 */

查找器​

按照 XenForo 软件架构中实体与查找器严格对应的原则,在源代码目录根XF/Finder中创建TrophyCategoryFinder.php

PHP
<?php

namespace XF\Finder;

use XF\Mvc\Entity\Finder;
use XF\Entity\TrophyCategory;
use XF\Mvc\Entity\AbstractCollection;

/**
 * @method AbstractCollection<TrophyCategory> fetch(?int $limit = null, ?int $offset = null)
 * @method AbstractCollection<TrophyCategory> fetchDeferred(?int $limit = null, ?int $offset = null)
 * @method TrophyCategory|null fetchOne(?int $offset = null)
 * @extends Finder<TrophyCategory>
 */
class TrophyCategoryFinder extends Finder {}

Repo​

实体对象库(Repo)负责保存未直接与实体模型数据相关联的第二层查询。在源根XF/Repository创建TrophyCategoryRepository.php

PHP
<?php

namespace XF\Repository;

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

class TrophyCategoryRepository extends Repository {
    public function getDefaultCategory() {
        $trophyCategory = $this->em->create(TrophyCategory::class);
        $trophyCategory->setTrusted('trophy_category_id', 0);
        $trophyCategory->setTrusted('display_order', 0);
        $trophyCategory->setReadOnly(true);

        return $trophyCategory;
    }

    public function findTrophyCategoriesForList($getDefault = false) {
        $categories = $this->finder(TrophyCategoryFinder::class)
                           ->with('MasterTitle')
                           ->order(['display_order'])
                           ->fetch();

        if ($getDefault) {
            $defaultCategory = $this->getDefaultCategory();
            $trophyCategories = $categories->toArray();
            $trophyCategories = [$defaultCategory] + $trophyCategories;
            $categories = $this->em->getBasicCollection($trophyCategories);
        }

        return $categories;
    }

    public function getTrophyCategoryTitlePairs() {
        $trophyCategories = $this->finder(TrophyCategoryFinder::class)
                                 ->order('display_order');

        return $trophyCategories->fetch()->pluckNamed('title', 'trophy_category_id');
    }
}

此 Repo 允许调用方法以获取一个默认的成就分类,并允许获取视图层可用的成品成就分类列表,以及获取形如“成就 ID => 成就标题”的供显示键值对。

由于成就与之分类的关系为双向,故需要在现有的TrophyRepository.php中添加相关的查询逻辑:

PHP
// 添加方法以用于获取已按照分类分组的成就数据
public function getTrophyListData() {
    $trophies = $this->findTrophiesForList()->fetch();

    $trophyCategories = $this->getTrophyCategoryForRepo()
        ->findTrophyCategoriesForList(true);

    return [
        'trophyCategories'    => $trophyCategories,
        'totalTrophies'        => $trophies->count(),
        'trophies'            => $trophies->groupBy('trophy_category_id'),
    ];
}

// 添加实用方法以用于获取成就分类的 Repo
/* @return TrophyCategoryRepository */
protected function getTrophyCategoryForRepo() {
    return $this->repository(TrophyCategoryRepository::class);
}

控制器​

按照 XenForo 软件架构中后台实体业务逻辑存放于相对应Admin控制器中的原则,在源代码目录根XF/Admin/Controller创建TrophyCategoryController.php

PHP
<?php

namespace XF\Admin\Controller;

use XF;
use XF\Mvc\FormAction;
use XF\Mvc\ParameterBag;
use XF\Entity\TrophyCategory;
use XF\Repository\TrophyRepository;
use XF\ControllerPlugin\DeletePlugin;

class TrophyCategoryController extends AbstractController {
    protected function preDispatchController($action, ParameterBag $params) {
        $this->assertAdminPermission('trophy');
    }

    public function actionIndex() {
        return $this->redirectPermanently($this->buildLink('trophies'));
    }

    public function trophyCategoryAddEdit(TrophyCategory $trophyCategory) {
        $viewParams = [
            'trophyCategory' => $trophyCategory,
        ];
        return $this->view('XF:TrophyCategory\Edit', 'trophy_category_edit', $viewParams);
    }

    public function actionEdit(ParameterBag $params) {
        $trophyCategory = $this->assertTrophyCategoryExists($params['trophy_category_id']);
        return $this->trophyCategoryAddEdit($trophyCategory);
    }

    public function actionAdd() {
        $trophyCategory = $this->em()->create(TrophyCategory::class);
        return $this->trophyCategoryAddEdit($trophyCategory);
    }

    public function trophySaveProcess(TrophyCategory $trophyCategory) {
        $entityInput = $this->filter([
            'display_order'    => 'uint',
        ]);

        $form = $this->formAction();
        $form->basicEntitySave($trophyCategory, $entityInput);

        $titlePhrase = $this->filter('title', 'str');

        $form->validate(function (FormAction $form) use ($titlePhrase) {
            if ($titlePhrase === '') {
                $form->logError(XF::phrase('please_enter_valid_title'), 'title');
            }
        });

        $form->apply(function () use ($titlePhrase, $trophyCategory) {
            $masterTitle = $trophyCategory->getMasterPhrase();
            $masterTitle->phrase_text = $titlePhrase;
            $masterTitle->save();
        });

        return $form;
    }

    public function actionSave(ParameterBag $params) {
        $this->assertPostOnly();

        if ($params['trophy_category_id']) {
            $trophyCategory = $this->assertTrophyCategoryExists($params['trophy_category_id']);
        } else {
            $trophyCategory = $this->em()->create(TrophyCategory::class);
        }

        $this->trophySaveProcess($trophyCategory)->run();

        return $this->redirect($this->buildLink('trophies'));
    }

    public function actionDelete(ParameterBag $params) {
        $trophyCategory = $this->assertTrophyCategoryExists($params['trophy_category_id']);

        /** @var DeletePlugin $plugin */
        $plugin = $this->plugin(DeletePlugin::class);
        return $plugin->actionDelete(
            $trophyCategory,
            $this->buildLink('trophy-categories/delete', $trophyCategory),
            $this->buildLink('trophy-categories/edit', $trophyCategory),
            $this->buildLink('trophies'),
            $trophyCategory->title,
            'trophy_category_delete'
        );
    }

    /**
     * @param string $id
     * @param array|string|null $with
     * @param null|string $phraseKey
     *
     * @return TrophyCategory
     */
    protected function assertTrophyCategoryExists($id, $with = null, $phraseKey = null) {
        return $this->assertRecordExists(TrophyCategory::class, $id, $with, $phraseKey);
    }

    /** @return TrophyCategoryRepository */
    protected function getTrophyCategoryRepo() {
        return $this->repository(TrophyCategoryRepository::class);
    }
}

该类作为一个符合 XenForo 惯例的标准控制器,用于连接成就分类实体,以实现操作数据的目的。

方法描述
preDispatchController此控制器预调度器负责检查当前用户的相关权限。
actionIndexindex路由映射响应请求并按需执行重定向。
trophyCategoryAddEdit用于向视图模板传递最终参数的内部方法。
actionEditedit路由映射响应编辑请求,然后返回编辑视图。
actionAddadd路由映射响应创建请求,然后返回编辑视图。
trophySaveProcess此方法校验数据的有效性,然后在模型层创建最终的成就分类实体。
actionSavesave路由映射响应POST至此的保存表单请求,然后执行保存并在完成时进行重定向。
actionDeletedelete路由映射响应删除请求,检查目标分类是否存在,然后执行删除。
assertTrophyCategoryExists断言目标成就分类存在,并在不存在时返回相应信息。
getTrophyCategoryRepo用于获取成就分类所在 Repo 的实用方法。

由于成就分类与成就本身的关系是双向的,故成就控制器中也需要引入新的逻辑代码。对于TrophyController.php

PHP
// 修改 actionIndex 以使现在的视图获取经过分类分组的成就数据
public function actionIndex() {
    $options = $this->em()->findByIds(Option::class, ['enableTrophies', 'userTitleLadderField']);

    $viewParams = [
        'trophyData'    => $this->getTrophyRepo()->getTrophyListData(),
        'options'        => $options,
    ];
    return $this->view('XF:Trophy\Listing', 'trophy_list', $viewParams);
}

模板​

完成控制器层之后,创建相应的后台视图模板,以呈现最终页面。

XML
<template type="admin" title="trophy_category_edit" version_id="2030770" version_string="2.3.7"><![CDATA[<xf:if is="$trophyCategory.isInsert()">
    <xf:title>{{ phrase('add_trophy_category') }}</xf:title>
<xf:else />
    <xf:title>{{ phrase('edit_trophy_category:') }}{$trophyCategory.title}</xf:title>
</xf:if>

<xf:pageaction if="$trophyCategory.isUpdate()">
    <xf:button href="{{ link('trophy-categories/delete', $trophyCategory) }}" icon="delete" overlay="true" />
</xf:pageaction>

<xf:form action="{{ link('trophy-categories/save', $trophyCategory) }}" ajax="true" class="block">
    <div class="block-container">
        <div class="block-body">
            <xf:textboxrow name="title" value="{$trophyCategory}" label="{{ phrase('title') }}"/>
            <xf:macro id="display_order_macros::row" arg-value="{$trophyCategory}" />
        </div>

        <xf:submitrow icon="save" />
    </div>
</xf:form>]]></template>

XML
<template type="admin" title="trophy_category_delete" version_id="2030770" version_string="2.3.7"><![CDATA[<xf:title>{{ phrase('confirm_action') }}</xf:title>

<xf:form action="{$confirmUrl}" ajax="true" class="block">
    <div class="block-container">
        <div class="block-body">
            <xf:inforow rowtype="confirm">
                {{ phrase('please_confirm_that_you_want_to_delete_following') }}
                <strong><a href="{$editUrl}">{$contentTitle}</a></strong>
                <div class="blockMessage blockMessage--important blockMessage--iconic">{{ phrase('confirm_delete_trophy_category_footnote') }}</div>
            </xf:inforow>
        </div>
        <xf:submitrow rowtype="simple" icon="delete" />
    </div>
</xf:form>]]></template>

同时修改原有的trophy_edit模板,以开始允许用户在编辑成就时为其指定分类。

XML
<template type="admin" title="trophy_edit" version_id="2030010" version_string="2.3.0 Alpha"><![CDATA[

    <!-- 在 name 为 description 的 xf:textarearow 下方插入: -->

    <xf:selectrow name="trophy_category_id" value="{$trophy}" label="{{ phrase('trophy_category') }}">
        <xf:option value="0" label="{{ phrase('(none)') }}" />
        <xf:options source="{$trophyCategories}" />
    </xf:selectrow>

    <!-- ... -->
]]></template>

最后修改trophy_list模板,因为原模版中的成就列表是未经过分类分组的,而在引入成就分类之后,需要将其修改为已分组状态:

XML
<template type="admin" title="trophy_list" version_id="2030010" version_string="2.3.0 Alpha"><![CDATA[<xf:title>{{ phrase('trophies') }}</xf:title>

<xf:pageaction>
    <xf:button href="{{ link('trophies/add') }}" icon="add">{{ phrase('add_trophy') }}</xf:button>
    <xf:button href="{{ link('trophy-categories/add') }}" icon="add" overlay="true">{{ phrase('add_category') }}</xf:button>
</xf:pageaction>

<div class="block">
    <div class="block-outer">
       <xf:macro id="filter_macros::quick_filter" arg-key="trophies" arg-class="block-outer-opposite" />
    </div>
    <div class="block-container">
       <div class="block-body">
          <xf:datalist>
             <xf:foreach loop="$trophyData.trophyCategories" key="$trophyCategoryId" value="$trophyCategory">
                <tbody class="dataList-rowGroup">
                   <xf:if is="{{ count($trophyData.trophyCategories) > 1 }}">
                      <xf:datarow rowtype="subsection" rowclass="{{ !$trophyCategoryId ? 'dataList-row--noHover' : '' }}">
                         <xf:if is="{{ $trophyCategoryId > 0 }}">
                            <xf:cell href="{{ link('trophy-categories/edit', $trophyCategory) }}" colspan="3" overlay="true">
                               {{ $trophyCategory.title }}
                            </xf:cell>
                            <xf:delete href="{{ link('trophy-categories/delete', $trophyCategory) }}" />
                         <xf:else />
                            <xf:cell colspan="4">{{ phrase('trophy_category_title.uncategorized') }}</xf:cell>
                         </xf:if>
                      </xf:datarow>
                   </xf:if>

                   <xf:foreach loop="{$trophyData.trophies.{$trophyCategoryId}}" key="$trophyId" value="$trophy">
                      <xf:datarow
                         label="{$trophy.title}"
                         hint="{{ phrase('points:') }}{$trophy.trophy_points}"
                         href="{{ link('trophies/edit', $trophy) }}"
                         delete="{{ link('trophies/delete', $trophy) }}" />
                   <xf:else />
                      <xf:datarow rowclass="dataList-row--noHover dataList-row--note">
                         <xf:cell colspan="4" class="dataList-cell--noSearch">
                            <xf:if is="{{ count($trophyData.trophyCategories) > 1 }}">
                               {{ phrase('no_trophies_have_been_added_to_this_category_yet') }}
                            <xf:else />
                               {{ phrase('no_trophies_have_been_added_yet') }}
                            </xf:if>
                         </xf:cell>
                      </xf:datarow>
                   </xf:foreach>
                </tbody>
             </xf:foreach>
          </xf:datalist>
       </div>
       <div class="block-footer">
          <span class="block-footer-counter">{{ display_totals($trophies) }}</span>
       </div>
    </div>
</div>

<xf:macro id="option_macros::option_form_block" arg-options="{$options}" />]]></template>

路由​

在完整地配置了成就分类的代码与模板内容后,方可在 XenForo 全局路由配置文件routes.xml中声明新的成就分类路由,以使所有控制器起作用。

在该文件中找到route_prefix="trophies"的条目,然后直接在下方添加新路由:

XML
<route route_type="admin" route_prefix="trophy-categories" format=":int&lt;trophy_category_id&gt;/" controller="XF:TrophyCategory" context="trophies"/>

短语​

为了确保 UI 中的文本能够正确地显示,应向插入如下短语:

XML
<phrase title="trophy_category" version_id="2030770" version_string="2.3.7"><![CDATA[成就分类]]></phrase>
<phrase title="trophy_category_title.uncategorized" version_id="2030770" version_string="2.3.7"><![CDATA[未分类成就]]></phrase>
<phrase title="no_trophies_have_been_added_to_this_category_yet" version_id="2030770" version_string="2.3.7"><![CDATA[当前分类没有添加任何成就。]]></phrase>
<phrase title="edit_trophy_category" version_id="2030770" version_string="2.3.7"><![CDATA[编辑成就分类]]></phrase>
<phrase title="confirm_delete_trophy_category_footnote" version_id="2030770" version_string="2.3.7"><![CDATA[删除后,所有该分类下的成就将被移动至未分类。]]></phrase>
<phrase title="add_trophy_category" version_id="2030770" version_string="2.3.7"><![CDATA[创建成就分类]]></phrase>