Search by

chinablog / music-form

910708548

动态表单封装:一处定义组件,同时产出前端渲染契约与服务端校验规则

Package info

gitee.com/china002/music-form.git

pkg:composer/chinablog/music-form

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

v1.3.0 2026-10-30 03:15 UTC

This package is not auto-updated.

Last update: 2026-10-31 02:01:29 UTC


README

动态表单封装:组件是对象,规则也是对象。一处定义,同时产出「前端渲染契约」与「服务端校验规则」。

零框架依赖,只需要 PHP 8.1。规则最终产成 Hyperf / Laravel 风格的规则数组, 交给宿主框架现成的验证器执行 —— 本包不含验证器,不重复造轮子。

形态对齐 CRMEB 多商户在用的 xaboy/form-builder: 组件靠静态工厂造出来,表单收的是 FormComponentDriver 那样的对象, 而不是 add('input', …) 这种类型字符串。

它解决什么

常见的动态表单实现(CRMEB、以及多数 Java 版本)有两个共同的缺口:

  1. 服务端零校验:后端只下发前端 rules,绕过前端直接 POST,想提交什么就提交什么。
  2. 规则是拼出来的字符串:'required|string|max:50' 这种写法,把同一条约束 在前端提示、后端校验、必填判断三处各表述一遍,改一处漏一处就是前后端不一致。

本包把这两件事收进对象里:组件是对象(类型走白名单),规则也是对象 (Required / Max / In / Regex …),每条规则自己同时知道前端怎么提示、后端怎么校验。

安装

上传到 Composer 远程仓库后:

composer require chinablog/music-form

没上传仓库前,怎么装进当前项目

用 Composer 官方的 path repository,把本地目录软链进 vendor/,改包即时生效:

# 在项目根目录(注意:容器内路径要能对上,两边是同一个挂载点)
composer config repositories.music-form path ../music-form
composer require chinablog/music-form:@dev

装完是软链,不是拷贝:

vendor/chinablog/music-form -> ../../../music-form/

⚠️ 这只是开发期的临时方案,两个坑要记住:

  • 部署机会挂 —— 打包/部署的机器上没有 ../music-form 这个目录,composer install 会解析失败。 正式发布前必须切回远程仓库。
  • composer.lock 会变 —— path 包在 lock 里记的是本地路径,切回远程后 lock 内容不同, 发布时记得重新生成并检查。

切回远程仓库的姿势:

composer remove chinablog/music-form
composer config --unset repositories.music-form
composer require chinablog/music-form

快速上手

use Chinablog\Form\Form;

$form = Form::make('歌单设置', '/api/admin/playlists')
    ->setModeType('page')
    ->append(
        Form::title('基础信息'),
        Form::input('title', '歌单名')
            ->placeholder('请输入歌单名')
            ->maxlength(50)
            ->required('请输入歌单名'),
        Form::number('sort', '排序')->min(0)->max(9999),
        Form::checkbox('tags', '风格')->items(['流行' => 1, '摇滚' => 2, '民谣' => 3]),
    );

⚠️ 参数顺序统一 (字段名, 显示名),与 Form::input($field, $title) 一致。 两个都是字符串,顺序记反不会报错、只会静默错位 —— 所以整个包只认这一种顺序。

① 前端契约 —— $form->toArray() / toJson(),结构与 scForm 这类渲染器一致:

{
  "title": "歌单设置", "action": "/api/admin/playlists", "method": "POST",
  "labelWidth": "100px", "labelPosition": "right", "modeType": "page",
  "submitText": "保存", "resetText": "重置",
  "formItems": [
    { "label": "基础信息", "component": "title" },
    { "label": "歌单名", "name": "title", "component": "input", "value": null,
      "options": { "type": "text", "placeholder": "请输入歌单名", "clearable": true, "maxlength": 50 },
      "rules": [
        { "required": true, "message": "请输入歌单名", "trigger": "blur" },
        { "max": 50, "message": "最多 50 个字符", "trigger": "blur" }
      ] }
  ]
}

② 校验规则(对象) —— $form->rules() 返回 字段名 => RuleSet:

$form->rules()['title']->isRequired();        // true
$form->rules()['title']->first(Max::class);   // Max 对象
$form->rules()['tags.*'];                     // 数组元素的规则集

③ 交给验证器 —— $form->validatorRules():

[
    'title'  => 'required|string|max:50',
    'sort'   => 'nullable|integer|min:0|max:9999',
    'tags'   => 'nullable|array',
    'tags.*' => 'in:1,2,3',
]

// Hyperf 里
$validator = $factory->make($request->all(), $form->validatorRules());

规则对象

规则都在 Chinablog\Form\Rules\ 下,实现同一个 Rule 接口:

规则后端前端
Required::make('请输入')required{required:true, message, trigger}
Nullable::make()nullable—
Type::string() / integer() / numeric() / array()同名—
Max::make(10) / Max::length(50) / Max::count(3)max:N{max:N, message}
Min::make(1) / Min::length(2) / Min::count(1)min:N{min:N, message}
In::make([1,2]) / In::of(1,0)in:1,2—
Regex::make('/^#fff$/', '提示')regex:/^#fff$/{pattern:'^#fff$'}

前端一栏是空的类型规则,是因为输入框本来就输不出数组,再弹一次「必须是字符串」是噪音; 但后端那条不能省 —— 绕过前端直接 POST 时,它才是拦住 title=[1,2] 的那道关。

组件上追加规则用 validate()(与 form-builder 的 validate() 对齐):

Form::input('title', '歌单名')->validate(
    Required::make('请输入歌单名'),
    Max::length(50, '最多 50 个字'),
);

// required() 就是 validate(Required::make(...)) 的简写
Form::input('title', '歌单名')->required('请输入歌单名');

两个坑,都由对象自己兜住

1. in 里的逗号。 Hyperf / Laravel 的 in 规则按逗号切分,值里带逗号时不加引号 会被拆成两个合法值。这段逻辑归 In 对象自己管,组件不碰字符串:

In::make(['S,M', 'L'])->backend();   // ['in:"S,M","L"']

2. 规则里的 |。 规则串用 | 拼、验证器解析也按 | 切,带分支的正则 (取色器的 #abc|#abcdef)拼成字符串会被切成两半,报 Method Validator::validate[09aFAF]{6})$/ does not exist 这种完全看不懂的错。 RuleSet::toValidator() 检测到片段含 | 时,整个字段改用数组形式输出 (数组元素不会被再切一次),正则保持原样。

整个包只有 RuleSet::toValidator() 一处会把规则拼成字符串。

其它设计要点

数组型组件必须产出 字段.*

多选类组件的值是数组。光写 tags => array 只校验了「它是个数组」, tags = ['恶意值'] 能一路过。所以 checkbox / images / 多选 select / 范围 date 会额外产出一条子规则,$form->rules() 里以 tags.* 为键。

hidden 由后端算好,前端零求值

原实现下发 hideHandle / requiredHandle 表达式字符串,由前端 eval() 执行 —— 后端数据一旦被篡改就是前端代码执行。本包改成后端计算后下发布尔值:

  • 隐藏项不进 formItems(配置不外泄、payload 更小)
  • 隐藏项不进 rules()(它压根不会被提交,required 只会误伤正常请求)

契约里不会出现 hideHandle / requiredHandle 任何字段。

分组标题用 label,不是 title

老版后端输出 {component:'title', title:xxx},前端读的是 item.label —— 两边对不上,分组标题永远显示为空。本包从源头统一成 label。

组件类型走白名单

未知类型在构建时就抛 UnknownComponentException(异常信息带上可用类型), 而不是渲染时静默透传一个空壳给前端。

组件清单

静态工厂类后端规则要点
Form::input()InputType::string();maxlength/minlength → Max/Min
Form::textarea()TextareaType::string();maxlength → Max
Form::number()Number有 precision → Type::numeric(),否则 Type::integer()
Form::radio()RadioIn 锁定选项值
Form::checkbox()CheckboxType::array() + 字段.* 的 In
Form::select()Select单选 In;多选 Type::array() + 字段.* 的 In
Form::switches()SwitchesIn 锁定 active/inactive 值
Form::image()ImageType::string()
Form::images()ImagesType::array() + Max::count() + 字段.* 的 Type::string()
Form::file()FileType::string()
Form::color()ColorType::string() + hex 正则(防 XSS 载体)
Form::date()Date范围选择 → Type::array() + 字段.*
Form::editor()EditorType::string(),不做内容过滤
Form::title()Title分组标题,不产生字段

switches 而不是 switch:switch 是 PHP 保留字,不能做类名。 Editor 的 XSS 净化交给输出转义或存储前的净化层,组件不代劳。

从配置数组构建(接表时用)

接 m_system_group_fields 这类表时,读出来的行喂给 FormFactory。分工很清楚:

  • ComponentRegistry 决定「这行配置交给哪个类」(类型白名单)
  • 那个类的 fromArray() 决定「怎么读这行配置」(自己认自己的 props)
use Chinablog\Form\FormFactory;

$form = FormFactory::fromArray([
    'title'  => '歌单设置',
    'action' => '/api/admin/playlists',
    'items'  => [
        ['component' => 'title', 'label' => '基础信息'],
        [
            'component' => 'input', 'label' => '歌单名', 'name' => 'title',
            'options'   => ['placeholder' => '请输入', 'maxlength' => '50'],
            'required'  => true,
        ],
        [
            'component' => 'radio', 'label' => '状态', 'name' => 'status',
            'options'   => ['items' => ['启用' => 1, '禁用' => 0]],
        ],
    ],
]);

要点:

  • options 只装组件 props。每个组件在 applyOptions() 里显式声明自己认哪些键, 多的直接忽略 —— 不用反射去按字符串键名猜 setter,配置里多一个键不会调到不该调的方法。
  • 库里 json 取出的数字常常是字符串({"maxlength":"50"}),applyOptions() 里显式转换。
  • 校验规则大多由 props 自动推导(maxlength → Max、items → In), 所以配置里通常只需写 props + required;要完整规则就传 rules => [Rule 对象]。
  • 字段名重复抛 DuplicateFieldException —— 重名会让后写的规则静默顶掉先写的, 排查起来很费劲,宁可在构建时炸掉。

自定义组件

实现 ComponentInterface(type/frontendOptions/rules/rulesMap/fromArray/toArray), 注册进注册表即可。在启动阶段注册(Bootstrap / ConfigProvider), 不要在请求处理中调用 register() —— 常驻内存下状态会跨请求残留。

$registry = (new ComponentRegistry())->register('rate', Rate::class);

$form = (new Form($registry))->append(Rate::make('score', '评分'));
$form = FormFactory::fromArray($schema, $registry);

不一定非要继承 FormItem,直接实现接口也行(tests/Fixtures/FakeComponent.php 就是这么写的)。

测试

composer install
composer test          # 或 ./vendor/bin/phpunit

examples/demo.php 打印一份完整表单的三份产出; examples/verify-with-hyperf.php 把规则喂给 Hyperf 真实验证器跑 13 个用例 (需要在项目里装了 hyperf/validation,用 HYPERF_ROOT 指过去):

HYPERF_ROOT=/你的项目根目录 php examples/verify-with-hyperf.php

dev 依赖锁在 phpunit/phpunit ^10.5.63(含 CVE-2026-24765 修复, 低版本是 PHPT 覆盖率文件反序列化可致 RCE)。 若用阿里云 Composer 镜像,它可能还没同步该版本导致解析失败, 临时直连官方源即可:COMPOSER_HOME=/tmp/packagist composer install。

License

MIT