CMS 教程 - 标签和用户

构建了基本的文章创建功能后,我们需要允许多个作者在我们的 CMS 中协作。之前,我们手动构建了所有模型、视图和控制器。这次,我们将使用 Bake 控制台 来创建我们的骨架代码。Bake 是一款强大的代码生成 CLI 工具,它利用 CakePHP 使用的约定来非常有效地创建骨架 CRUD 应用程序。我们将使用 bake 来构建我们的用户代码

cd /path/to/our/app

# You can overwrite any existing files.
bin/cake bake model users
bin/cake bake controller users
bin/cake bake template users

这 3 个命令将生成

  • 表、实体、夹具文件。

  • 控制器

  • CRUD 模板。

  • 每个生成的类的测试用例。

Bake 还将使用 CakePHP 约定来推断模型的关联和验证。

向文章添加标签功能

允许多个用户访问我们的小型 CMS 后,最好有一个对内容进行分类的方法。我们将使用标签和标签功能来允许用户为他们的内容创建自由形式的类别和标签。同样,我们将使用 bake 快速生成一些应用程序的骨架代码

# Generate all the code at once.
bin/cake bake all tags

创建脚手架代码后,通过访问 https://127.0.0.1:8765/tags/add 创建一些示例标签。

现在我们有了标签表,可以创建文章和标签之间的关联。我们可以在 ArticlesTable 上的 initialize 方法中添加以下内容来实现

public function initialize(array $config): void
{
    $this->addBehavior('Timestamp');
    $this->belongsToMany('Tags'); // Add this line
}

由于我们在创建表时遵循了 CakePHP 约定,因此此关联将使用此简单的定义正常工作。有关更多信息,请阅读 关联 - 将表链接在一起.

更新文章以启用标签功能

现在我们的应用程序有了标签,我们需要允许用户标记他们的文章。首先,更新 add 操作,使其看起来像这样

<?php
// in src/Controller/ArticlesController.php
namespace App\Controller;

use App\Controller\AppController;

class ArticlesController extends AppController
{
    public function add()
    {
        $article = $this->Articles->newEmptyEntity();
        if ($this->request->is('post')) {
            $article = $this->Articles->patchEntity($article, $this->request->getData());

            // Hardcoding the user_id is temporary, and will be removed later
            // when we build authentication out.
            $article->user_id = 1;

            if ($this->Articles->save($article)) {
                $this->Flash->success(__('Your article has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('Unable to add your article.'));
        }
        // Get a list of tags.
        $tags = $this->Articles->Tags->find('list')->all();

        // Set tags to the view context
        $this->set('tags', $tags);

        $this->set('article', $article);
    }

    // Other actions
}

添加的行将标签列表加载为 id => title 的关联数组。这种格式将让我们在模板中创建一个新的标签输入。在 templates/Articles/add.php 中的 PHP 控制块中添加以下内容

echo $this->Form->control('tags._ids', ['options' => $tags]);

这将渲染一个使用 $tags 变量生成选择框选项的多选元素。您现在应该创建一些带有标签的新文章,因为在下一节中,我们将添加按标签查找文章的功能。

您还应该更新 edit 方法以允许添加或编辑标签。编辑方法现在应该看起来像这样

public function edit($slug)
{
    $article = $this->Articles
        ->findBySlug($slug)
        ->contain('Tags') // load associated Tags
        ->firstOrFail();
    if ($this->request->is(['post', 'put'])) {
        $this->Articles->patchEntity($article, $this->request->getData());
        if ($this->Articles->save($article)) {
            $this->Flash->success(__('Your article has been updated.'));

            return $this->redirect(['action' => 'index']);
        }
        $this->Flash->error(__('Unable to update your article.'));
    }

    // Get a list of tags.
    $tags = $this->Articles->Tags->find('list')->all();

    // Set tags to the view context
    $this->set('tags', $tags);

    $this->set('article', $article);
}

请记住,将我们添加到 add.php 模板中的新标签多选控制也添加到 templates/Articles/edit.php 模板中。

按标签查找文章

用户对内容进行分类后,他们将希望通过他们使用的标签找到这些内容。对于此功能,我们将实现一个路由、控制器操作和查找器方法来按标签搜索文章。

理想情况下,我们的 URL 应该像 https://127.0.0.1:8765/articles/tagged/funny/cat/gifs 一样。这将让我们找到所有带有“funny”、“cat”或“gifs”标签的文章。在实施此功能之前,我们将添加一条新路由。您的 config/routes.php(删除烘焙的注释后)应该看起来像这样

<?php
use Cake\Routing\Route\DashedRoute;
use Cake\Routing\RouteBuilder;

$routes->setRouteClass(DashedRoute::class);

$routes->scope('/', function (RouteBuilder $builder) {
    $builder->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']);
    $builder->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']);

    // Add this
    // New route we're adding for our tagged action.
    // The trailing `*` tells CakePHP that this action has
    // passed parameters.
    $builder->scope('/articles', function (RouteBuilder $builder) {
        $builder->connect('/tagged/*', ['controller' => 'Articles', 'action' => 'tags']);
    });

    $builder->fallbacks();
});

以上定义了一条新“路由”,它将 /articles/tagged/ 路径连接到 ArticlesController::tags()。通过定义路由,您可以将 URL 的外观与其实现方式隔离开来。如果我们访问 https://127.0.0.1:8765/articles/tagged,我们将看到 CakePHP 提供的帮助错误页面,告知您控制器操作不存在。现在让我们实现这个缺少的方法。在 src/Controller/ArticlesController.php 中添加以下内容

public function tags()
{
    // The 'pass' key is provided by CakePHP and contains all
    // the passed URL path segments in the request.
    $tags = $this->request->getParam('pass');

    // Use the ArticlesTable to find tagged articles.
    $articles = $this->Articles->find('tagged', tags: $tags)
        ->all();

    // Pass variables into the view template context.
    $this->set([
        'articles' => $articles,
        'tags' => $tags
    ]);
}

要访问请求数据的其他部分,请查阅 请求 部分。

由于传递的参数作为方法参数传递,因此您也可以使用 PHP 的可变参数编写操作

public function tags(...$tags)
{
    // Use the ArticlesTable to find tagged articles.
    $articles = $this->Articles->find('tagged', tags: $tags)
        ->all();

    // Pass variables into the view template context.
    $this->set([
        'articles' => $articles,
        'tags' => $tags
    ]);
}

创建查找器方法

在 CakePHP 中,我们希望将控制器操作保持精简,并将大部分应用程序逻辑放在模型层中。如果您现在访问 /articles/tagged URL,您将看到一条错误消息,告知您尚未实现 findTagged() 方法,所以让我们来实现它。在 src/Model/Table/ArticlesTable.php 中添加以下内容

// add this use statement right below the namespace declaration to import
// the Query class
use Cake\ORM\Query\SelectQuery;

// The $query argument is a query builder instance.
// The $options array will contain the 'tags' option we passed
// to find('tagged') in our controller action.
public function findTagged(SelectQuery $query, array $tags = []): SelectQuery
{
    $columns = [
        'Articles.id', 'Articles.user_id', 'Articles.title',
        'Articles.body', 'Articles.published', 'Articles.created',
        'Articles.slug',
    ];

    $query = $query
        ->select($columns)
        ->distinct($columns);

    if (empty($tags)) {
        // If there are no tags provided, find articles that have no tags.
        $query->leftJoinWith('Tags')
            ->where(['Tags.title IS' => null]);
    } else {
        // Find articles that have one or more of the provided tags.
        $query->innerJoinWith('Tags')
            ->where(['Tags.title IN' => $tags]);
    }

    return $query->groupBy(['Articles.id']);
}

我们刚刚实现了一个 自定义查找器方法。这是 CakePHP 中一个非常强大的概念,它允许您打包可重用的查询。查找器方法总是接收一个 查询构建器 对象和一个选项数组作为参数。查找器可以操作查询并添加任何必要的条件或标准。完成时,查找器方法必须返回一个修改后的查询对象。在我们的查找器中,我们利用了 distinct()leftJoin() 方法,这些方法允许我们找到具有“匹配”标签的不同文章。

创建视图

现在,如果您再次访问 /articles/tagged URL,CakePHP 将显示一条新错误消息,告知您尚未创建视图文件。接下来,让我们构建 tags() 操作的视图文件

<!-- In templates/Articles/tags.php -->
<h1>
    Articles tagged with
    <?= $this->Text->toList(h($tags), 'or') ?>
</h1>

<section>
<?php foreach ($articles as $article): ?>
    <article>
        <!-- Use the HtmlHelper to create a link -->
        <h4><?= $this->Html->link(
            $article->title,
            ['controller' => 'Articles', 'action' => 'view', $article->slug]
        ) ?></h4>
        <span><?= h($article->created) ?></span>
    </article>
<?php endforeach; ?>
</section>

在上面的代码中,我们使用了 HtmlText 帮助程序来帮助生成我们的视图输出。我们还使用了 h 快捷函数来对 HTML 进行编码输出。您应该记住,在输出数据时始终使用 h() 来防止 HTML 注入问题。

我们刚刚创建的 tags.php 文件遵循 CakePHP 视图模板文件的约定。约定是让模板使用控制器操作名称的小写和下划线版本。

您可能会注意到,我们能够在视图模板中使用 $tags$articles 变量。当我们在控制器中使用 set() 方法时,我们设置了要发送到视图的特定变量。视图将在模板作用域中将所有传递的变量作为局部变量提供。

您现在应该能够访问 /articles/tagged/funny URL 并查看所有带有“funny”标签的文章。

改进标签体验

现在,添加新标签是一个繁琐的过程,因为作者需要预先创建所有他们想要使用的标签。我们可以通过使用逗号分隔的文本字段来改进标签选择 UI。这将让我们为用户提供更好的体验,并在 ORM 中使用更多强大的功能。

添加计算字段

因为我们希望有一个简单的方法来访问实体的格式化标签,所以我们可以向实体添加一个虚拟/计算字段。在 src/Model/Entity/Article.php 中添加以下内容

// add this use statement right below the namespace declaration to import
// the Collection class
use Cake\Collection\Collection;

// Update the accessible property to contain `tag_string`
protected array $_accessible = [
    //other fields...
    'tag_string' => true
];

protected function _getTagString()
{
    if (isset($this->_fields['tag_string'])) {
        return $this->_fields['tag_string'];
    }
    if (empty($this->tags)) {
        return '';
    }
    $tags = new Collection($this->tags);
    $str = $tags->reduce(function ($string, $tag) {
        return $string . $tag->title . ', ';
    }, '');

    return trim($str, ', ');
}

这将让我们访问 $article->tag_string 计算属性。我们将在后面的控件中使用此属性。

更新视图

更新实体后,我们可以为标签添加一个新的控件。在 templates/Articles/add.phptemplates/Articles/edit.php 中,将现有的 tags._ids 控件替换为以下内容

echo $this->Form->control('tag_string', ['type' => 'text']);

我们还需要更新文章视图模板。在 templates/Articles/view.php 中添加如下所示的行

<!-- File: templates/Articles/view.php -->

<h1><?= h($article->title) ?></h1>
<p><?= h($article->body) ?></p>
// Add the following line
<p><b>Tags:</b> <?= h($article->tag_string) ?></p>

您也应该更新视图方法以允许检索现有标签。

// src/Controller/ArticlesController.php file

public function view($slug = null)
{
   // Update retrieving tags with contain()
   $article = $this->Articles
        ->findBySlug($slug)
        ->contain('Tags')
        ->firstOrFail();
    $this->set(compact('article'));
}

持久化标签字符串

现在我们可以将现有标签作为字符串查看,我们希望将该数据保存起来。因为我们标记了 tag_string 为可访问的,ORM 将从请求中复制该数据到我们的实体。我们可以使用 beforeSave() 钩子方法来解析标签字符串并查找/构建相关的实体。将以下内容添加到 src/Model/Table/ArticlesTable.php

public function beforeSave(EventInterface $event, $entity, $options)
{
    if ($entity->tag_string) {
        $entity->tags = $this->_buildTags($entity->tag_string);
    }

    // Other code
}

protected function _buildTags($tagString)
{
    // Trim tags
    $newTags = array_map('trim', explode(',', $tagString));
    // Remove all empty tags
    $newTags = array_filter($newTags);
    // Reduce duplicated tags
    $newTags = array_unique($newTags);

    $out = [];
    $tags = $this->Tags->find()
        ->where(['Tags.title IN' => $newTags])
        ->all();

    // Remove existing tags from the list of new tags.
    foreach ($tags->extract('title') as $existing) {
        $index = array_search($existing, $newTags);
        if ($index !== false) {
            unset($newTags[$index]);
        }
    }
    // Add existing tags.
    foreach ($tags as $tag) {
        $out[] = $tag;
    }
    // Add new tags.
    foreach ($newTags as $tag) {
        $out[] = $this->Tags->newEntity(['title' => $tag]);
    }

    return $out;
}

如果您现在创建或编辑文章,您应该能够将标签保存为用逗号分隔的标签列表,并且标签和链接记录将自动创建。

虽然这段代码比我们之前做的要复杂一些,但它有助于展示 CakePHP 中的 ORM 有多么强大。您可以使用 集合 方法来操作查询结果,并轻松处理动态创建实体的情况。

自动填充标签字符串

在我们完成之前,我们需要一个机制,无论何时加载文章,它都会加载关联的标签(如果有)。

在您的 src/Model/Table/ArticlesTable.php 中,更改

public function initialize(array $config): void
{
    $this->addBehavior('Timestamp');
    // Change this line
    $this->belongsToMany('Tags', [
        'joinTable' => 'articles_tags',
        'dependent' => true
    ]);
}

这将告诉 Articles 表模型存在与标签关联的联接表。'dependent' 选项告诉表在删除文章时从联接表中删除任何关联的记录。

最后,更新 src/Controller/ArticlesController.php 中的 findBySlug() 方法调用

public function edit($slug)
{
    // Update this line
    $article = $this->Articles
        ->findBySlug($slug)
        ->contain('Tags')
        ->firstOrFail();
...
}

public function view($slug = null)
{
    // Update this line
    $article = $this->Articles
        ->findBySlug($slug)
        ->contain('Tags')
        ->firstOrFail();
    $this->set(compact('article'));
}

contain() 方法告诉 ArticlesTable 对象在加载文章时也填充 Tags 关联。现在,当为 Article 实体调用 tag_string 时,将存在数据来创建字符串!

接下来我们将添加 身份验证