chinablog / music-form
动态表单封装:一处定义组件,同时产出前端渲染契约与服务端校验规则
Requires
- php: ^8.1
Requires (Dev)
- phpunit/phpunit: ^10.5.63
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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 版本)有两个共同的缺口:
- 服务端零校验:后端只下发前端
rules,绕过前端直接 POST,想提交什么就提交什么。 - 规则是拼出来的字符串:
'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() | Input | Type::string();maxlength/minlength → Max/Min |
Form::textarea() | Textarea | Type::string();maxlength → Max |
Form::number() | Number | 有 precision → Type::numeric(),否则 Type::integer() |
Form::radio() | Radio | In 锁定选项值 |
Form::checkbox() | Checkbox | Type::array() + 字段.* 的 In |
Form::select() | Select | 单选 In;多选 Type::array() + 字段.* 的 In |
Form::switches() | Switches | In 锁定 active/inactive 值 |
Form::image() | Image | Type::string() |
Form::images() | Images | Type::array() + Max::count() + 字段.* 的 Type::string() |
Form::file() | File | Type::string() |
Form::color() | Color | Type::string() + hex 正则(防 XSS 载体) |
Form::date() | Date | 范围选择 → Type::array() + 字段.* |
Form::editor() | Editor | Type::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