s2/admin-yard

无重量级依赖的简单管理面板

dev-master 2024-08-12 16:39 UTC

This package is auto-updated.

Last update: 2024-09-12 16:50:22 UTC


README

AdminYard 是一个轻量级的 PHP 库,用于构建无重量级依赖(如框架、模板引擎或 ORM)的管理面板。它提供了以对象风格定义实体、字段及其属性的声明性配置。使用 AdminYard,您可以快速为数据库表设置 CRUD(创建、读取、更新、删除)接口,并根据需要对其进行定制。

AdminYard 简化了创建典型管理界面的过程,让您可以专注于开发核心功能。它不会试图创建尽可能多的功能的抽象。相反,它解决常见的管理任务,同时提供足够的扩展点来根据您的特定项目进行定制。

在开发 AdminYard 时,我受到了 EasyAdmin 的启发。我想用它来为我的一个项目使用,但我不想引入像 Symfony、Doctrine 或 Twig 这样的主要依赖项。因此,我尝试制作了一个没有这些依赖项的类似产品。它可以嵌入到现有的旧项目中,在这些项目中添加新的框架和 ORM 不是很容易。如果您从头开始一个新的项目,我建议您首先考虑使用 Symfony、Doctrine 和 EasyAdmin。

安装

要安装 AdminYard,您可以使用 Composer

composer require s2/admin-yard

用法

以下是带有说明的配置示例。您还可以查看一个更完整的示例应用程序

集成

安装完成后,您可以通过创建一个 AdminConfig 实例并使用您的实体设置对其进行配置来开始使用 AdminYard。然后,创建一个 AdminPanel 实例,传入 AdminConfig 实例。使用 handleRequest 方法处理传入的请求并生成管理面板 HTML。

<?php

use S2\AdminYard\DefaultAdminFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;

// Config for admin panel, see below
$adminConfig = require 'admin_config.php';

// Typical AdminYard services initialization.
// You can use DI instead and override services if required.
$pdo = new PDO('mysql:host=localhost;dbname=adminyard', 'username', 'passwd');
$adminPanel = DefaultAdminFactory::createAdminPanel($adminConfig, $pdo, require 'translations/en.php', 'en');

// AdminYard uses Symfony HTTP Foundation component.
// Sessions are required to store flash messages.
// new Session() stands for native PHP sessions. You can provide an alternative session storage.
$request = Request::createFromGlobals();
$request->setSession(new Session());
$response = $adminPanel->handleRequest($request);
$response->send();

字段、过滤器、多对一和多对多关联的基本配置示例

<?php

declare(strict_types=1);

use S2\AdminYard\Config\AdminConfig;
use S2\AdminYard\Config\DbColumnFieldType;
use S2\AdminYard\Config\EntityConfig;
use S2\AdminYard\Config\FieldConfig;
use S2\AdminYard\Config\Filter;
use S2\AdminYard\Config\FilterLinkTo;
use S2\AdminYard\Config\LinkTo;
use S2\AdminYard\Database\PdoDataProvider;
use S2\AdminYard\Event\AfterSaveEvent;
use S2\AdminYard\Event\BeforeDeleteEvent;
use S2\AdminYard\Event\BeforeSaveEvent;
use S2\AdminYard\Validator\NotBlank;
use S2\AdminYard\Validator\Length;

$adminConfig = new AdminConfig();

$commentConfig = new EntityConfig(
    'Comment', // Entity name in interface
    'comments' // Database table name
);

$postEntity = (new EntityConfig('Post', 'posts'))
    ->addField(new FieldConfig(
        name: 'id',
        type: new DbColumnFieldType(FieldConfig::DATA_TYPE_INT, true), // Primary key
        // Show ID only on list and show screens
        useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW] 
    ))
    ->addField(new FieldConfig(
        name: 'title',
        // DATA_TYPE_STRING may be omitted as it is default:
        // type: new DbColumnFieldType(FieldConfig::DATA_TYPE_STRING),
        // Form control must be defined since new and edit screens are not excluded in useOnActions 
        control: 'input', // Input field for title
        validators: [new Length(max: 80)], // Form validators may be supplied
        sortable: true, // Allow sorting on the list screen
        actionOnClick: 'edit' // Link from cell on the list screen
    ))
    ->addField(new FieldConfig(
        name: 'text',
        control: 'textarea', // Textarea for post content
        // All screens except list
        useOnActions: [FieldConfig::ACTION_SHOW, FieldConfig::ACTION_EDIT, FieldConfig::ACTION_NEW]
    ))
    ->addField(new FieldConfig(
        name: 'created_at',
        type: new DbColumnFieldType(FieldConfig::DATA_TYPE_TIMESTAMP), // Timestamp field
        control: 'datetime', // Date and time picker
        sortable: true // Allow sorting by creation date
    ))
    ->addField(new FieldConfig(
        name: 'comments',
        // Special config for one-to-many association. Will be displayed on the list and show screens
        // as a link to the comments list screen with a filter on posts applied.
        type: new LinkedByFieldType(
            $commentConfig, 
            'CASE WHEN COUNT(*) > 0 THEN COUNT(*) ELSE NULL END', // used as text in link 
            'post_id'
        ),
        sortable: true
    ))
    ->addFilter(new Filter(
        'search',
        'Fulltext Search',
        'search_input',
        'title LIKE %1$s OR text LIKE %1$s',
        fn(string $value) => $value !== '' ? '%' . $value . '%' : null // Transformer for PDO parameter
    ))
;

// Fields and filters configuration for "Comment"
$commentConfig
    ->addField(new FieldConfig(
        name: 'id',
        type: new DbColumnFieldType(FieldConfig::DATA_TYPE_INT, true), // Primary key
        useOnActions: [] // Do not show on any screen
    ))
    ->addField($postIdField = new FieldConfig(
        name: 'post_id',
        type: new DbColumnFieldType(FieldConfig::DATA_TYPE_INT), // Foreign key to post
        control: 'autocomplete', // Autocomplete control for selecting post
        validators: [new NotBlank()], // Ensure post_id is not blank
        sortable: true, // Allow sorting by title
        // Special config for one-to-many association. Will be displayed on the list and show screens
        // as a link to the post. "CONCAT('#', id, ' ', title)" is used as a link text.
        linkToEntity: new LinkTo($postEntity, "CONCAT('#', id, ' ', title)"),
        // Disallow on edit screen, post may be chosen on comment creation only.
        useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW, FieldConfig::ACTION_NEW]
    ))
    ->addField(new FieldConfig(
        name: 'name',
        control: 'input', // Input field for commenter's name
        validators: [new NotBlank(), new Length(max: 50)],
        inlineEdit: true, // Allow to edit commentator's name on the list screen
    ))
    ->addField(new FieldConfig(
        name: 'email',
        control: 'email_input',
        validators: [new Length(max: 80)], // Max length validator
        inlineEdit: true,
    ))
    ->addField(new FieldConfig(
        name: 'comment_text',
        control: 'textarea',
    ))
    ->addField(new FieldConfig(
        name: 'created_at',
        type: new DbColumnFieldType(FieldConfig::DATA_TYPE_TIMESTAMP), // Timestamp field
        control: 'datetime', // Date and time picker
        sortable: true // Allow sorting by creation date on the list screen
    ))
    ->addField(new FieldConfig(
        name: 'status_code',
        // defaultValue is used for new entities when the creating form has no corresponding field
        type: new DbColumnFieldType(FieldConfig::DATA_TYPE_STRING, defaultValue: 'new'),
        control: 'radio', // Radio buttons for status selection
        options: ['new' => 'Pending', 'approved' => 'Approved', 'rejected' => 'Rejected'],
        inlineEdit: true,
        // Disallow on new screen
        useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW, FieldConfig::ACTION_EDIT]
    ))
    ->addFilter(new Filter(
        'search',
        'Fulltext Search',
        'search_input',
        'name LIKE %1$s OR email LIKE %1$s OR comment_text LIKE %1$s',
        fn(string $value) => $value !== '' ? '%' . $value . '%' : null
    ))
    ->addFilter(new FilterLinkTo(
        $postIdField, // Filter comments by a post on the list screen
        'Post',
    ))
    ->addFilter(new Filter(
        'created_from',
        'Created after',
        'date',
        'created_at >= %1$s', // Filter comments created after a certain date
    ))
    ->addFilter(new Filter(
        'created_to',
        'Created before',
        'date',
        'created_at < %1$s', // Filter comments created before a certain date
    ))
    ->addFilter(new Filter(
        'statuses',
        'Status',
        'checkbox_array', // Several statuses can be chosen at once
        'status_code IN (%1$s)', // Filter comments by status
        options: ['new' => 'Pending', 'approved' => 'Approved', 'rejected' => 'Rejected']
    ));

// Add entities to admin config
$adminConfig
    ->addEntity($postEntity)
    ->addEntity($commentConfig);

return $adminConfig;

带有虚拟字段和多对多关联的高级示例

实体字段可以直接映射到相应表中的列,也可以是虚拟的,根据某些规则动态计算。

继续上一个示例,假设帖子与 posts_tags 表中的标签存在多对多关系。如果我们想在标签列表中显示相关帖子的数量,我们可以使用以下构造

<?php

use S2\AdminYard\Config\EntityConfig;
use S2\AdminYard\Config\Filter;

$tagConfig = new EntityConfig('Tag', 'tags');
$tagConfig
    ->addField(new FieldConfig(
        name: 'name',
        control: 'input',
    ))
    ->addField(new FieldConfig(
        name: 'used_in_posts', // Arbitrary field name
        type: new VirtualFieldType(
            // Query evaluates the content of the virtual field
            'SELECT CAST(COUNT(*) AS CHAR) FROM posts_tags AS pt WHERE pt.tag_id = entity.id',
            // We can define a link to the post list.
            // To make this work, a filter on tags must be set up for posts, see below
            new LinkToEntityParams('Post', ['tags'], ['name' /* Tag property name, i.e. tags.name */])
        ),
        // Read-only field, new and edit actions are disabled.
        useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW]
    ))
;

$postConfig
    ->addFilter(
        new Filter(
            name: 'tags',
            label: 'Tags',
            control: 'search_input',
            whereSqlExprPattern:  'id IN (SELECT pt.post_id FROM posts_tags AS pt JOIN tags AS t ON t.id = pt.tag_id WHERE t.name LIKE %1$s)',
            fn(string $value) => $value !== '' ? '%' . $value . '%' : null
        )
    )
;

在这个示例中,虚拟字段 used_in_posts 被声明为只读。我们不能通过它编辑 posts_tags 表中的关系。

访问控制

AdminYard 不了解系统用户、他们的角色或权限。然而,由于其动态和灵活的配置,您可以在配置本身中编程角色和权限的差异。让我们看看一些示例。

一次控制所有实体的操作访问权限

$postEntity->setEnabledActions([
    FieldConfig::ACTION_LIST,
    ...isGranted('author') ? [FieldConfig::ACTION_EDIT, FieldConfig::ACTION_DELETE] : [],
]);

要控制不是所有实体的访问权限,而是在行级别,可以在 LogicalExpression 中指定额外的条件,这些条件包含在查询的 WHERE 子句中,并限制读取(操作 listshow)和写入(操作 editdelete)的行级访问权限

// If the power_user role is not granted, show only approved comments 
if (!isGranted('power_user')) {
    $commentEntity->setReadAccessControl(new LogicalExpression('status_code', 'approved'));
}

条件可以更复杂。它们可以包括外部参数,如 $currentUserId,以及表中的列值

if (!isGranted('editor')) {
    // If the editor role is not granted, the user can only see their own posts
    // or those that are already published.
    $postEntity->setReadAccessControl(
        new LogicalExpression('read_access_control_user_id', $currentUserId, "status_code = 'published' OR user_id = %s")
    );
    
    // If the editor role is not granted, the user can only edit or delete their own posts. 
    $postEntity->setWriteAccessControl(new LogicalExpression('user_id', $currentUserId));
} 

除了限制对整个行的访问,还可以控制对单个字段的访问。

$commentEntity->addField(new FieldConfig(
    name: 'email',
    control: 'email_input',
    validators: [new Length(max: 80)],
    // Hide the field value from users without sufficient access level
    useOnActions: isGranted(power_user) ? [FieldConfig::ACTION_EDIT, FieldConfig::ACTION_LIST] : [],
));
$commentEntity->addField(new FieldConfig(
    name: 'status_code',
    type: new DbColumnFieldType(FieldConfig::DATA_TYPE_STRING, defaultValue: 'new'),
    control: 'radio',
    options: ['new' => 'Pending', 'approved' => 'Approved', 'rejected' => 'Rejected'],
    // Allow inline editing of this field on the list screen for users with the moderator role.
    // Inline editing does not take into account the condition specified in setWriteAccessControl,
    // to allow partial editing of the entity for users without full editing rights.
    inlineEdit: isGranted('moderator'),
    useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW, FieldConfig::ACTION_EDIT]
));

架构

AdminYard 在三个数据表示级别上运行

  • HTTP:发送和接收的 POST 请求的表单 HTML 代码。
  • 在控制器组件之间传递的规范化数据。
  • 数据库中持久化的数据。

在转换这些级别之间时,数据根据实体配置进行转换。为了在HTTP级别和规范化数据之间转换,使用表单控件。为了在规范化数据与数据库中持久化的数据之间转换,使用dataTypedataType的值必须基于数据库中的列类型和存储在其中的数据的含义来选择。例如,如果您有一个类型为VARCHAR的数据库列,您不能将dataType指定为bool,因为当写入数据库时,TypeTransformer会将所有值转换为整数0或1,这与字符串数据类型不兼容。

应理解,并非所有表单控件都与基于dataType从数据库读取时TypeTransformer生成的规范化数据兼容。

选择控件和数据类型

以下是根据数据库列类型和所需的表单控件选择数据类型的建议。

关于PHP中的规范化类型说明

如您所注意到的,在规范化数据类型中,字符串通常用于代替专用类型,特别是对于int和float。这是出于两个原因。首先,用于输入数字的控件是常规输入,浏览器输入的数据作为字符串传输到服务器。因此,中间值被选为字符串。其次,以字符串形式传输数据,而不进行中间转换为float,可以避免在处理浮点数时可能出现的精度损失。

列字段和虚拟字段

配置定义中的所有字段分为两大类:列字段和虚拟字段。它们由DbColumnFieldType和VirtualFieldType类描述。列字段直接对应于数据库表中的列。多对一关联也被视为列字段,因为它们通常用像entity_id这样的引用表示。AdminYard支持列字段的全部CRUD操作。此外,AdminYard通过LinkedByFieldType支持一对一关联,它是VirtualFieldType的子类。

要使用VirtualFieldType,需要编写一个SQL查询来评估虚拟字段中显示的内容。

当执行到数据库的SELECT查询时,需要检索列字段值和虚拟字段值。为了避免冲突,列字段名称以column_前缀,虚拟字段名称以virtual_前缀。如果没有这种分隔,使用new LinkTo($postEntity, "CONCAT('#", id, " ", title)')的多对一关联将无法工作,因为需要同时检索链接内容和链接地址的实体标识符。这些前缀在将数据从POST请求传递到修改数据库查询时不会添加到表单控件名称或数组键中。

配置示例:通过事件监听器编辑虚拟字段

为了为帖子分配标签,让我们在帖子中创建一个虚拟字段tags,它可以接受用逗号分隔的标签。AdminYard没有内置此功能,但它有数据流中各个点的各种事件,允许手动实现此功能。

<?php

use S2\AdminYard\Config\EntityConfig;
use S2\AdminYard\Config\FieldConfig;
use S2\AdminYard\Config\VirtualFieldType;
use S2\AdminYard\Database\Key;
use S2\AdminYard\Database\PdoDataProvider;
use S2\AdminYard\Event\AfterSaveEvent;
use S2\AdminYard\Event\BeforeDeleteEvent;
use S2\AdminYard\Event\AfterLoadEvent;
use S2\AdminYard\Event\BeforeSaveEvent;

$postConfig
    ->addField(new FieldConfig(
        name: 'tags',
        // Virtual field, SQL query evaluates the content for list and show screens 
        type: new VirtualFieldType('SELECT GROUP_CONCAT(t.name SEPARATOR ", ") FROM tags AS t JOIN posts_tags AS pt ON t.id = pt.tag_id WHERE pt.post_id = entity.id'),
        // Form control for new and edit forms
        control: 'input',
    ))
    ->addListener([EntityConfig::EVENT_AFTER_EDIT_FETCH], function (AfterLoadEvent $event) {
        if (\is_array($event->data)) {
            // Convert NULL to an empty string when the edit form is filled with current data.
            // It is required since TypeTransformer is not applied to virtual fields (no dataType).
            // 'virtual_' prefix is used for virtual fields as explained earlier.
            $event->data['virtual_tags'] = (string)$event->data['virtual_tags'];
        }
    })
    ->addListener([EntityConfig::EVENT_BEFORE_UPDATE, EntityConfig::EVENT_BEFORE_CREATE], function (BeforeSaveEvent $event) {
        // Save the tags to context for later use and remove before updating and inserting.
        $event->context['tags'] = $event->data['tags'];
        unset($event->data['tags']);
    })
    ->addListener([EntityConfig::EVENT_AFTER_UPDATE, EntityConfig::EVENT_AFTER_CREATE], function (AfterSaveEvent $event) {
        // Process the saved tags. Convert the comma-separated string to an array to store in the many-to-many relation.
        $tagStr = $event->context['tags'];
        $tags   = array_map(static fn(string $tag) => trim($tag), explode(',', $tagStr));
        $tags   = array_filter($tags, static fn(string $tag) => $tag !== '');

        // Fetching tag IDs, creating new tags if required
        $newTagIds = tagIdsFromTags($event->dataProvider, $tags);

        // Fetching old links
        $existingLinks = $event->dataProvider->getEntityList('posts_tags', [
            'post_id' => FieldConfig::DATA_TYPE_INT,
            'tag_id'  => FieldConfig::DATA_TYPE_INT,
        ], conditions: [new Condition('post_id', $event->primaryKey->getIntId())]);
        $existingTagIds = array_column($existingLinks, 'column_tag_id');
        
        // Check if the new tag list differs from the old one
        if (implode(',', $existingTagIds) !== implode(',', $newTagIds)) {
            // Remove all old links
            $event->dataProvider->deleteEntity(
                'posts_tags',
                ['post_id' => FieldConfig::DATA_TYPE_INT],
                new Key(['post_id' => $event->primaryKey->getIntId()]),
                [],
            );
            // And create new ones
            foreach ($newTagIds as $tagId) {
                $event->dataProvider->createEntity('posts_tags', [
                    'post_id' => FieldConfig::DATA_TYPE_INT,
                    'tag_id'  => FieldConfig::DATA_TYPE_INT,
                ], ['post_id' => $event->primaryKey->getIntId(), 'tag_id' => $tagId]);
            }
        }
    })
    ->addListener(EntityConfig::EVENT_BEFORE_DELETE, function (BeforeDeleteEvent $event) {
        $event->dataProvider->deleteEntity(
            'posts_tags',
            ['post_id' => FieldConfig::DATA_TYPE_INT], 
            new Key(['post_id' => $event->primaryKey->getIntId()]),
            [],
        );
    })
;

// Fetching tag IDs, creating new tags if required
function tagIdsFromTags(PdoDataProvider $dataProvider, array $tags): array
{
    $existingTags = $dataProvider->getEntityList('tags', [
        'name' => FieldConfig::DATA_TYPE_STRING,
        'id'   => FieldConfig::DATA_TYPE_INT,
    ], conditions: [new Condition('name', array_map(static fn(string $tag) => mb_strtolower($tag), $tags), 'LOWER(name) IN (%s)')]);

    $existingTagsMap = array_column($existingTags, 'column_name', 'column_id');
    $existingTagsMap = array_map(static fn(string $tag) => mb_strtolower($tag), $existingTagsMap);
    $existingTagsMap = array_flip($existingTagsMap);

    $tagIds = [];
    foreach ($tags as $tag) {
        if (!isset($existingTagsMap[mb_strtolower($tag)])) {
            $dataProvider->createEntity('tags', ['name' => FieldConfig::DATA_TYPE_STRING], ['name' => $tag]);
            $newTagId = $dataProvider->lastInsertId();
        } else {
            $newTagId = $existingTagsMap[mb_strtolower($tag)];
        }
        $tagIds[] = $newTagId;
    }

    return $tagIds;
}

贡献

如果您有改进的建议,请提交拉取请求。

许可

AdminYard在MIT许可下发布。有关详细信息,请参阅LICENSE。