# MusicForm **Repository Path**: china002/music-form ## Basic Information - **Project Name**: MusicForm - **Description**: 动态表单封装:组件是对象,规则也是对象。一处定义,同时产出「前端渲染契约」与「服务端校验规则」。 零框架依赖,只需要 PHP 8.1。规则最终产成 Hyperf / Laravel 风格的规则数组, 交给宿主框架现成的验证器执行 —— 本包不含验证器,不重复造轮子。 - **Primary Language**: PHP - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-30 - **Last Updated**: 2026-10-30 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # chinablog/music-form 动态表单封装:**组件是对象,规则也是对象**。一处定义,同时产出「前端渲染契约」与「服务端校验规则」。 零框架依赖,只需要 PHP 8.1。规则最终产成 Hyperf / Laravel 风格的规则数组, 交给宿主框架现成的验证器执行 —— 本包**不含验证器**,不重复造轮子。 形态对齐 CRMEB 多商户在用的 [xaboy/form-builder](https://github.com/xaboy/form-builder): 组件靠静态工厂造出来,表单收的是 `FormComponentDriver` 那样的**对象**, 而不是 `add('input', …)` 这种类型字符串。 --- ## 它解决什么 常见的动态表单实现(CRMEB、以及多数 Java 版本)有两个共同的缺口: 1. **服务端零校验**:后端只下发前端 `rules`,绕过前端直接 POST,想提交什么就提交什么。 2. **规则是拼出来的字符串**:`'required|string|max:50'` 这种写法,把同一条约束 在前端提示、后端校验、必填判断三处各表述一遍,改一处漏一处就是前后端不一致。 本包把这两件事收进对象里:组件是对象(类型走白名单),规则也是对象 (`Required` / `Max` / `In` / `Regex` …),每条规则自己同时知道前端怎么提示、后端怎么校验。 --- ## 安装 上传到 Composer 远程仓库后: ```bash composer require chinablog/music-form ``` ### 没上传仓库前,怎么装进当前项目 用 Composer 官方的 **path repository**,把本地目录软链进 `vendor/`,改包即时生效: ```bash # 在项目根目录(注意:容器内路径要能对上,两边是同一个挂载点) 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 内容不同, 发布时记得重新生成并检查。 **切回远程仓库的姿势**: ```bash composer remove chinablog/music-form composer config --unset repositories.music-form composer require chinablog/music-form ``` ## 快速上手 ```php 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 这类渲染器一致: ```json { "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`: ```php $form->rules()['title']->isRequired(); // true $form->rules()['title']->first(Max::class); // Max 对象 $form->rules()['tags.*']; // 数组元素的规则集 ``` **③ 交给验证器** —— `$form->validatorRules()`: ```php [ '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()` 对齐): ```php Form::input('title', '歌单名')->validate( Required::make('请输入歌单名'), Max::length(50, '最多 50 个字'), ); // required() 就是 validate(Required::make(...)) 的简写 Form::input('title', '歌单名')->required('请输入歌单名'); ``` ### 两个坑,都由对象自己兜住 **1. `in` 里的逗号。** Hyperf / Laravel 的 in 规则按逗号切分,值里带逗号时不加引号 会被拆成两个合法值。这段逻辑归 `In` 对象自己管,组件不碰字符串: ```php 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) ```php 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()` —— 常驻内存下状态会跨请求残留。 ```php $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` 就是这么写的)。 --- ## 测试 ```bash composer install composer test # 或 ./vendor/bin/phpunit ``` `examples/demo.php` 打印一份完整表单的三份产出; `examples/verify-with-hyperf.php` 把规则喂给 Hyperf 真实验证器跑 13 个用例 (需要在项目里装了 `hyperf/validation`,用 `HYPERF_ROOT` 指过去): ```bash 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