s2 / admin-yard
无重量级依赖的简单管理面板
Requires
- psr/log: ^1.1 || ^2.0 || ^3.0
- symfony/event-dispatcher: ^7.0
- symfony/http-foundation: ^7.0
- symfony/translation-contracts: ^3.5
Requires (Dev)
- codeception/codeception: ^5.1
- codeception/module-asserts: ^3.0
- codeception/module-phpbrowser: ^3.0
- league/commonmark: ^2.4
- symfony/error-handler: ^7.0
Suggests
- ext-pdo: *
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 子句中,并限制读取(操作 list
和 show
)和写入(操作 edit
和 delete
)的行级访问权限
// 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级别和规范化数据之间转换,使用表单控件。为了在规范化数据与数据库中持久化的数据之间转换,使用dataType
。dataType
的值必须基于数据库中的列类型和存储在其中的数据的含义来选择。例如,如果您有一个类型为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。