原生 XenForo 缺失了一个优秀 WEB 社区所需的、成熟成就系统的几大特性:成就图标、分类化,以及隐藏成就。
此文档是一个面向开发人员的示例,演示如何为成就添加分类拓扑,以及向现有的数据表中简单地插入几个新字段,以及编写相关业务逻辑。
分类化
从业务角度分析,成就的分类应当属于最简单的一种,它们的任务只有一个,那就是归拢特定的一群分类实体。鉴于原生 XenForo 系统已经存在另一种任务完全与之相同的分类——表情分类,故这里直接照搬后者的所有代码与逻辑即可。
数据库表
新增数据库表xf_trophy_category,且表结构如下:
| 字段名称 | 类型 | 长度 | 特征 | 描述 |
|---|---|---|---|---|
| trophy_category_id | INT | 10 | 无符号、非空、主键、自增 | 成就分类的主键 ID |
| display_order | INT | 10 | 无符号 | 显示顺序 |
原有数据库表xf_trophy新增字段:
| 字段名称 | 类型 | 长度 | 特征 | 描述 |
|---|---|---|---|---|
| trophy_category_id | INT | 10 | 无符号、非空 | 成就分类的 ID |
实体
在源代码目录根XF/Entity下新增TrophyCategory.php,抄SmileyCategory.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中新增成就分类的结构声明与业务逻辑:
$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*声明的方法都不会在代码中被显式地调用。这些方法会在尝试保存实体时,严格地按照驼峰命名拆分为对应的字段,然后自动执行校验。
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 相关的声明:
/**
* 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
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
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中添加相关的查询逻辑:
// 添加方法以用于获取已按照分类分组的成就数据
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
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 | 此控制器预调度器负责检查当前用户的相关权限。 |
| actionIndex | 此index路由映射响应请求并按需执行重定向。 |
| trophyCategoryAddEdit | 用于向视图模板传递最终参数的内部方法。 |
| actionEdit | 此edit路由映射响应编辑请求,然后返回编辑视图。 |
| actionAdd | 此add路由映射响应创建请求,然后返回编辑视图。 |
| trophySaveProcess | 此方法校验数据的有效性,然后在模型层创建最终的成就分类实体。 |
| actionSave | 此save路由映射响应POST至此的保存表单请求,然后执行保存并在完成时进行重定向。 |
| actionDelete | 此delete路由映射响应删除请求,检查目标分类是否存在,然后执行删除。 |
| assertTrophyCategoryExists | 断言目标成就分类存在,并在不存在时返回相应信息。 |
| getTrophyCategoryRepo | 用于获取成就分类所在 Repo 的实用方法。 |
由于成就分类与成就本身的关系是双向的,故成就控制器中也需要引入新的逻辑代码。对于TrophyController.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);
}
模板
完成控制器层之后,创建相应的后台视图模板,以呈现最终页面。
<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>
<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模板,以开始允许用户在编辑成就时为其指定分类。
<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模板,因为原模版中的成就列表是未经过分类分组的,而在引入成就分类之后,需要将其修改为已分组状态:
<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"的条目,然后直接在下方添加新路由:
<route route_type="admin" route_prefix="trophy-categories" format=":int<trophy_category_id>/" controller="XF:TrophyCategory" context="trophies"/>
短语
为了确保 UI 中的文本能够正确地显示,应向插入如下短语:
<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>