z-song / demo.laravel-admin.org

Source code of official http://demo.laravel-admin.org website.
507 stars 250 forks source link

基于目前架构的Master-Detail实现方法分享 #33

Open robinhoo1973 opened 6 years ago

robinhoo1973 commented 6 years ago

由于master-detail表单形式在一对多和多对多的数据结构中常常使用.所以在不改动目前架构代码的情况下加了个外挂,希望z-song可以加入下个版本,如果能直接修改Form和Grid模块通过对Form加上属性Modal属性和相应方法,对Grid加上属性Detail等属性和相应方法,应该比外挂更容易实现,而且bug会少很多.

下面先说我的外挂组成:分别是ModalForm.php和DetailGrid.php两个外挂类型用来实现modal form和内嵌表格的主要功能和设置,其中ModalForm.php会引用客制化view:admin.extensions.modal_form 三个文件分别的存储位置是,其中{$project_dir}是你项目的目录地址 {$project_dir}/vendor/encore/laravel-admin/src/ModalForm.php

<?php
namespace Encore\Admin;

use Closure;
use Encore\Admin\Exception\Handler;
use Illuminate\Database\Eloquent\Model as EloquentModel;
use Encore\Admin\Form;
use Encore\Admin\Form\Tools;
use Encore\Admin\Form\Builder;
use Encore\Admin\Form\Field;
use Encore\Admin\Form\Field\File;
use Encore\Admin\Form\Row;
use Encore\Admin\Form\Tab;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Input;
use Illuminate\Support\MessageBag;
use Illuminate\Support\Str;
use Illuminate\Validation\Validator;
use Spatie\EloquentSortable\Sortable;
use Symfony\Component\HttpFoundation\Response;

class ModalForm
{
    public $form_name;
    public $modal_form;
    /**
     * Create a new form instance.
     *
     * @param $model
     * @param \Closure $callback
     */
    protected function getModel($model)
    {
        if ($model instanceof EloquentModel) {
            return $model;
        }

        if (is_string($model) && class_exists($model)) {
            return $this->getModel(new $model());
        }

        throw new InvalidArgumentException("$model is not a valid model");
    }

    protected function formatFormName($form_name)
    {
        return 'Modal_Form_'.str_replace(' ','_',trim(ucwords(preg_replace('/([^(A-Za-z0-9)])+/',' ',$form_name))));
    }

    public function __construct($model, $callback,$form_name='default')
    {
        $this->form_name    = $this->formatFormName($form_name);
        $this->modal_form   = new Form($this->getModel($model),$callback);
        $this->modal_form->disableSubmit();
        $this->modal_form->disableReset();
        $this->modal_form->setView('admin.extensions.modal_form');
    }

    public function render()
    {
        $this->modal_form->builder()->getTools()->disableBackButton();
        $this->modal_form->builder()->getTools()->disableListButton();
        $render=preg_replace('/<form /i',"<form id='{$this->form_name}' ",$this->modal_form->render());
        $render=str_replace('__MODAL_FORM_NAME__',"{$this->form_name}",$render);
        $render=str_replace('__SUBMIT__',trans('admin.submit'),$render);
        $render=str_replace('__RESET__',trans('admin.reset'),$render);
        $admin_new=trans('admin.new');
        $admin_create=trans('admin.create');
        $admin_edit=trans('admin.edit');
        $admin_delete=trans('admin.delete');
        $admin_delete_confirm=trans('admin.delete_confirm');
        $admin_cancel=trans('admin.cancel');
        $admin_confirm=trans('admin.confirm');
        $script=<<<SCRIPT
window.modal_form_mode='';
window.modal_form_id='';
window.modal_form='';
function registerLoadOptions()
{
    var regEx=/(?:\\$\\(document\\)\\.on\\(\\'change\\',\\s*\\"\\.\\w+\\",\\s*function\\s*\\(\\)\\s*{\\s+)([^]*?)(?=\\.trigger)/g;
    var html=$('body').html();
    var match;
    window.modal_load_options=[];
    // console.log(window.modal_load_options);
    while ((match = regEx.exec(html)) !== null) 
    {
        var loadRegEx=/var target = \\$\\(this\\)\\.closest\\(\\'\\.fields-group\\'\\)\\.find\\(\\"\\.(\w+)\\"\\)/;
        if (loadRegEx.exec(match[1])!== null)
        {
            var name=loadRegEx.exec(match[1])[1];
            var loadUrlRegEx=/(?:\\$\\.get\\(\\")([^]*?)(?=\\"\\+this\\.value)/;
            var loadUrl=match[1].match(loadUrlRegEx);
            // console.log(loadUrlRegEx);
            // console.log(loadUrl);
            window.modal_load_options[name] = loadUrl[1];
        }
    }
    // console.log(window.modal_load_options);
}

function selectOptions(key,value)
{
    if (!Array.isArray(window.modal_load_options))
    {
        registerLoadOptions();
    }
    if (window.modal_load_options.hasOwnProperty(key))
    {
        var target = window.modal_form.find('select[name=' + key + ']');
        if (value === '')
        {
            target.find("option").remove();
            window.modal_form.find('[name=' + key + ']').val(value).trigger('change.select2');
        }
        else
        {

            $.when($.get(window.modal_load_options[key]+value, function (data) {
                target.find("option").remove();
                // console.log(data);
                $(target).select2({
                    data: $.map(data, function (d) {
                        d.id = d.id;
                        d.text = d.text;
                        return d;
                    })
                }).val(value).trigger('change.select2');
            })).done(function(){
                console.log('Options:['+key+']='+value+'Loaded!');
            });
        }
        return;
    }
    console.log('Form:['+window.modal_form.attr('id')+'] Options:['+key+']='+value+'Loaded!');
    window.modal_form.find('[name=' + key + ']').val(value).trigger('change.select2');
}

function domEquipment(key,value)
{
    var dom = window.modal_form.find('[name=' + key + ']');
    if (dom.is('select')) 
    {
        selectOptions(key,value);
    } 
    else 
    {
        switch (dom.attr("type"))
        {
            case "text":
            case "hidden":
            case "textarea":
                dom.val(value);
                break;
            case "radio":
            case "checkbox":
                dom.val(value);
                dom.each(function() 
                {
                    if ($(this).attr('value') == value) 
                    {
                        $(this).attr("checked", value);
                    }
                });
                break;
        }
    }
}

function populateForm(data) 
{
    console.log(data);
    $.each(JSON.parse(data), function(key, value) 
    {
        domEquipment(key,value);
    });
}

function apiModalEditData()
{
    $.get(window.modal_form.attr('action'), function (data) 
    {
        data=data.replace(/^\[/,'').replace(/\]$/,'');
        populateForm(data);
    });
}

function resetError()
{
    window.modal_form.find('.form-group').removeClass('has-error');
    window.modal_form.find('label[for=inputError]').remove();
}

function resetModalForm()
{
    window.modal_form.find('input:text, input:password, textarea').val('').trigger('change');
    window.modal_form.find('select').each(function(){
        console.log($(this).attr('name'));
        selectOptions($(this).attr('name'),'');
        $(this).val('').trigger('change.select2');
    }); 
    window.modal_form.find('input:radio, input:checkbox').prop('checked', false).trigger('change');
    window.modal_form.serializeArray().forEach(function(element){
        console.log('['+element.name+']="'+element.value+'"');
    });
    // window.modal_form.attr('action','');
    resetError();
}

$('.grid-row-edit .fa-edit').unbind('click').click(function() {

    var id      = $(this).data('id');
    var apiUrl  = $(this).data('url')+'/'+id+'?_ajax=1';
    window.modal_form = $('#'+$(this).data('form'));
    window.modal_form.closest('.modal').find('.modal-title').text("{$admin_edit}");
    window.modal_form_mode='edit';
    resetModalForm();
    window.modal_form.attr('action',apiUrl);
    console.log(window.modal_form.attr('action'));
    apiModalEditData();
    window.modal_form.attr('action',$(this).data('url')+'/'+id);
});

$('.grid-row-create').unbind('click').click(function() {
    var data=$(this).find('.fa-save');
    window.modal_form = $('#'+data.data('form'));
    var apiUrl  = data.data('url');
    console.log(apiUrl);
    window.modal_form.closest('.modal').find('.modal-title').text("{$admin_new}");
    window.modal_form_mode='create';
    resetModalForm();
    window.modal_form.attr('action',apiUrl);
});

$('.grid-row-delete .fa-trash').unbind('click').click(function() {

    var id      = $(this).data('id');
    var apiUrl  = $(this).data('url')+'/'+id;
    window.modal_form = $('#'+$(this).data('form'));
    swal({
      title: "{$admin_delete_confirm}",
      type: "warning",
      showCancelButton: true,
      confirmButtonColor: "#DD6B55",
      confirmButtonText: "{$admin_confirm}",
      closeOnConfirm: false,
      cancelButtonText: "{$admin_cancel}"
    },
    function(){
        $.ajax({
            method: 'post',
            url: apiUrl,
            data: {
                _method:'delete',
                _token:LA.token,
            },
            success: function (data) {
                $.pjax.reload('#pjax-container');

                if (typeof data === 'object') {
                    if (data.status) {
                        swal(data.message, '', 'success');
                    } else {
                        swal(data.message, '', 'error');
                    }
                }
            }
        });
    });
    window.modal_form.closest('.modal').modal('hide');
});

$('.modal_form_button').unbind('click').click(function()
{
    console.log($(this));
    type=$(this).data('type');
    window.modal_form=$('#'+$(this).data('form'));
    switch(type)
    {
        case 'reset':
            resetButtonClick();
            break;
        case 'submit':
            submitButtonClick();
    }
});

function submitButtonClick(){
    apiUrl=window.modal_form.attr('action');
    fields={};
        window.modal_form.serializeArray().forEach(function(element){
        fields[element.name]=element.value;
    });
    if (window.modal_form_mode=='edit')
    {
        fields['_method']='PUT';
    }
    fields['_ajax']=1;
    console.log(fields);
    console.log(apiUrl);
    $.ajaxSetup({
        headers: {
            'X-CSRF-TOKEN': LA.token
        }
    });
    $.ajax({
        url: apiUrl,
        method: (window.modal_form_mode=='edit')?'put':'post',
        data: fields,
        success: function(data)
        {
            console.log(data);
            resetError();
            if (typeof data === 'object') 
            {
                if (data.status) 
                {
                    window.modal_form.closest('.modal').modal('hide');
                    $.pjax.reload('#pjax-container');
                    swal(data.message, '', 'success');
                } 
                else 
                {
                    for (var key in data.message)
                    {
                        if (data.message.hasOwnProperty(key))
                        {
                            window.modal_form.find('[name='+key+']').closest('.form-group').addClass('has-error');
                            window.modal_form.find('[name='+key+']').closest('.col-sm-8').append(data.message[key]);
                            console.log(key+':');
                            console.log(data.message[key]);
                        }
                    };
                    //swal(data.message, '', 'error');
                }
            }
        },
        error: function (xhr, ajaxOptions, thrownError) {
            console.log(xhr);
            console.log(thrownError);
            swal("Internal Error!\\n"+xhr.status+':'+xhr.statusText, '', 'error');
        }
    });
}

function resetButtonClick(){
    console.log(window.modal_form_mode);
    var apiUrl=window.modal_form.attr('action');
    resetModalForm();

    if (window.modal_form_mode=='edit')
    {
        window.modal_form.attr('action',apiUrl+'?_ajax=1');
        console.log(window.modal_form.attr('action'));
        apiModalEditData();
        window.modal_form.attr('action',apiUrl);
    }
}
SCRIPT;
        Admin::script($script);
        return $render;
    }

    public function __toString()
    {
        return $this->render();
    }
}

{$project_dir}/vendor/encore/laravel-admin/src/DetailGrid.php

<?php

namespace Encore\Admin;

use Closure;
use Encore\Admin\Grid;
use Encore\Admin\Grid\Tools\AbstractTool;
use Illuminate\Database\Eloquent\Model as Eloquent;
use Illuminate\Support\Facades\Input;

class DetailGridCreateButton extends AbstractTool
{
    /**
     * Create a new CreateButton instance.
     *
     * @param Grid $grid
     */
    public function __construct(Grid $grid)
    {
        $this->grid = $grid;
    }

    /**
     * Render CreateButton.
     *
     * @return string
     */
    public function render()
    {
        return "<div class='btn-group pull-right' style='margin-right: 10px'><a href='' class='btn btn-sm btn-success grid-row-create' data-toggle='modal' data-target='#Main___MODAL_FORM_NAME__'><i class='fa fa-save' data-type='create' data-url='__URL__' data-form='__MODAL_FORM_NAME__'></i>&nbsp;&nbsp;".trans('admin.new')."</a></div>";
    }
}

class DetailGrid
{
    protected $form_name;
    public $grid;

    protected function accessProtected($obj, $prop) 
    {
        $reflection = new \ReflectionClass($obj);
        $property = $reflection->getProperty($prop);
        $property->setAccessible(true);
        return $property->getValue($obj);
    }

    public function getModel($model)
    {
        if ($model instanceof Eloquent) {
            return $model;
        }

        if (is_string($model) && class_exists($model)) {
            return $this->getModel(new $model());
        }

        throw new InvalidArgumentException("{$model} is not a valid model");
    }

    protected function formatFormName($form_name)
    {
        return 'Modal_Form_'.str_replace(' ','_',trim(ucwords(preg_replace('/([^(A-Za-z0-9)])+/',' ',$form_name))));
    }

    public function __construct(Eloquent $model, Closure $callback, $form_name='default')
    {

        $this->form_name= $this->formatFormName($form_name);
        $this->grid     = new Grid($model,$callback);
        $this->grid->disableCreation();
    }

    public function setFormName($form_name)
    {
        $this->form_name    = $this->formatFormName($form_name);
    }

    public function render()
    {
        //dd($this->grid);
        $this->grid->tools->prepend(new DetailGridCreateButton($this->grid));
        //dd($this->grid);
        $render=$this->grid->render();
        $render=str_replace('__MODAL_FORM_NAME__',"{$this->form_name}",$render);
        $render=str_replace('__URL__',"{$this->grid->resource()}",$render);
        //dd($render);
        return $render;
    }

    public function __toString()
    {
        return $this->render();
    }
}

{$project_dir}/resources/views/admin/extensions/modal_form.blade.php

<div class="modal" id="Main___MODAL_FORM_NAME__" tabindex="-1" role="dialog"  aria-labelledby="__MODAL_FORM_NAME__Label" aria-hidden="true">
    <div class="modal-dialog" role="document">
        <div class="modal-content">
            <div class="modal-header text-center">
                <button type="button" class="close" data-dismiss="#Main___MODAL_FORM_NAME__" aria-label="Close" >
                    <span aria-hidden="true">&times;</span>
                </button>
                <h4 class="modal-title w-100 font-weight-bold">{{ $form->title() }}</h4>
            </div>
            <div class="modal-body mx-3">
                 <!-- /.box-header -->
                <!-- form start -->
                @if($form->hasRows())
                    {!! $form->open() !!}
                @else
                    {!! $form->open(['class' => "form-horizontal ModalForm"]) !!}
                @endif

                    <div class="box-body">

                        @if(!$tabObj->isEmpty())
                            @include('admin::form.tab', compact('tabObj'))
                        @else
                            <div class="fields-group">

                                @if($form->hasRows())
                                    @foreach($form->getRows() as $row)
                                        {!! $row->render() !!}
                                    @endforeach
                                @else
                                    @foreach($form->fields() as $field)
                                        {!! $field->render() !!}
                                    @endforeach
                                @endif

                            </div>
                        @endif

                    </div>
                    <!-- /.box-body -->
                    <div class="box-footer">

                        @if( ! $form->isMode(\Encore\Admin\Form\Builder::MODE_VIEW)  || ! $form->option('enableSubmit'))
                            <input type="hidden" name="_token" value="{{ csrf_token() }}">
                        @endif
                        <div class="col-md-{{$width['label']}}">

                        </div>
                        <div class="col-md-{{$width['field']}}">

                            <div class="btn-group pull-right">
                            <button type="button" class="btn btn-info pull-right modal_form_button" data-type="submit" data-form="__MODAL_FORM_NAME__" data-loading-text="<i class='fa fa-spinner fa-spin '></i> __SUBMIT__">__SUBMIT__</button>
                            </div>

                            <div class="btn-group pull-left">
                            <button type="button" class="btn btn-warning modal_form_button" data-type="reset" data-form="__MODAL_FORM_NAME__">__RESET__</button>
                            </div>

                        </div>

                    @foreach($form->getHiddenFields() as $hiddenField)
                        {!! $hiddenField->render() !!}
                    @endforeach
                    </div>

                    <!-- /.box-footer -->
                {!! $form->close() !!}
            </div>
        </div>
    </div>
</div>
<!-- /.modal -->

然后先创建DetailGrid所对应的Controller 比如说,你的供应商对应多个站点,你就先建一个SiteController,实现功能是创建一个针对某个供应商的CRUD页面,注意要设好你的rules和对应的messages,因为之后的ModalForm校验就是通过这个Controller的.然后引入ModalForm的trait替换掉原来的ModelForm的trait.这个新的trait文件主要是来处理接受从ModalForm和DetailGrid发起的CRUD事件请求,同时兼顾目前Controller的网页请求的完整性. 这个ModalForm.php的trait文件存放位置是: {$project_dir}/app/Admin/Controllers/Extensions/includes/ModalForm.php

<?php
namespace App\Admin\Controllers\Extensions\includes;

trait ModalForm
{
    protected function accessProtected($obj, $prop) 
    {
        $reflection = new \ReflectionClass($obj);
        $property = $reflection->getProperty($prop);
        $property->setAccessible(true);
        return $property->getValue($obj);
    }

    public function ajaxValidators($form)
    {
        $rules=[];
        $messages=[];
        foreach ($form->builder()->fields() as $field) 
        {
            $rules[$this->accessProtected($field,'id')]=$this->accessProtected($field,'rules');
            foreach($this->accessProtected($field,'validationMessages') as $rule=>$message)
            {
                $messages[$this->accessProtected($field,'id').'.'.$rule]=$message;
            }
        }
        $validator = \Validator::make(\Illuminate\Support\Facades\Input::all(),$rules,$messages);
        if ($validator->fails()) 
        {
            $messages=[];
            foreach($validator->getMessageBag()->toArray() as $key=>$errors)
            {
                $messages[$key]='';
                foreach($errors as $error)
                {
                    $messages[$key].="<label class='control-label' for='inputError'><i class='fa fa-times-circle-o'></i> {$error}<br/></label>";
                }
            }
            return response([
                'status'  => false,
                'message' => $messages,
            ]);
        }
        return false;
    }

    /**
     * Display the specified resource.
     *
     * @param int $id
     *
     * @return \Illuminate\Http\Response
     */
    public function show($id)
    {
        if (array_key_exists('_ajax',\Illuminate\Support\Facades\Input::all()))
        {
            $model=$this->form()->model();
            $ret=$model->where($model->getKeyName(),'=',$id)->first();
            return $ret?json_encode($ret):'{}';
        }
        return $this->edit($id);
    }

    /**
     * Update the specified resource in storage.
     *
     * @param int $id
     *
     * @return \Illuminate\Http\Response
     */
    public function update($id)
    {
        if (array_key_exists('_ajax',\Illuminate\Support\Facades\Input::all()))
        {
            $form=$this->form($id);
            if ($response=$this->ajaxValidators($form))
            {
                return $response;
            }
            $this->form($id)->update($id);
            return response([
                'status'  => true,
                'message' => trans('admin.update_succeeded'),
            ]);
        }
        return $this->form($id)->update($id);
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param int $id
     *
     * @return \Illuminate\Http\Response
     */
    public function destroy($id)
    {
        if ($this->form()->destroy($id)) {
            return response()->json([
                'status'  => true,
                'message' => trans('admin.delete_succeeded'),
            ]);
        } else {
            return response()->json([
                'status'  => false,
                'message' => trans('admin.delete_failed'),
            ]);
        }
    }

    /**
     * Store a newly created resource in storage.
     *
     * @return \Illuminate\Http\Response
     */
    public function store()
    {
        if (array_key_exists('_ajax',\Illuminate\Support\Facades\Input::all()))
        {
            $form=$this->form();
            if ($response=$this->ajaxValidators($form))
            {
                return $response;
            }
            $this->form()->store();
            return response([
                'status'  => true,
                'message' => trans('admin.update_succeeded'),
            ]);
        }
        return $this->form()->store();
    }
}

然后创建你的Master-Detail表单,由于必须要有Master的记录才可以进行Detail记录的创建,所以我们主要是修改edit的方法,同时加上两个新的函数detailgrid和modalform.示例代码如下: 对edit方法的修改

    public function edit($id)
    {
        return Admin::content(function (Content $content) use ($id) {

            $content->header('服务机构信息');
            $content->description('服务机构信息维护');
            $content->body($this->form($id)->edit($id).$this->detailGrid($id).$this->modalForm($id));
        });
    }

同时会应用到下面的一个include文件,没办法写外挂都这样!!! 重构Grid行Action按钮的代码 {$project_dir}/app/Admin/Controllers/Extensions/includes/DetailGridActions.php

<?php
    $actions->disableEdit();
    $actions->disableDelete();
    $actions->prepend("<a href='javascript:void(0);' class='grid-row-delete'><i class='fa fa-trash' data-url='{$actions->getResource()}' data-form='__MODAL_FORM_NAME__' data-id='{$actions->getKey()}'></i></a>\n");
    $actions->prepend("<a href='javascript:void(0);'' data-toggle='modal' data-target='#Main___MODAL_FORM_NAME__' class='grid-row-edit'><i class='fa fa-edit' data-url='{$actions->getResource()}' data-form='__MODAL_FORM_NAME__' data-id='{$actions->getKey()}'></i></a>\n");
?>

新增的两个方法,分别对应DetailGrid和ModalForm

    protected function detailGrid($pid)
    {
        return new DetailGrid(new BackendAssitListViewSites, function (Grid $grid) use($pid){
            $grid->resource(admin_base_path('/site'));
            $grid->model()->where('supp_id','=',$pid);
            $grid->supp_name('服务机构')->sortable();
            $grid->province_name('省')->sortable();
            $grid->city_name('市')->sortable();
            $grid->area_name('区')->sortable();
            $grid->site_name('站点名称')->sortable();
            $grid->site_addr('站点地址')->sortable();
            $grid->contact_info('联系人信息');
            /****
            ***** 下面的设置非常重要是告诉用来重构调用ModalForm的编辑和删除按钮!!!
            *****/
            $grid->actions(function ($actions){
                include(__DIR__.'/Extensions/includes/DetailGridActions.php');
            });
        });
    }

    protected function modalForm($pid=null)
    {
        return new ModalForm(new \App\Models\Site, function (Form $form) use($pid)
        {
            /****
            ***** 下面的设置非常重要是告诉AJAX从这个Resource地址获取CRUD操作!!!
            *****/
            $form->resource(admin_base_path('/site'));

            /****
            ***** 下面两行的设置非常重要是告诉AJAX从对应的子表主键和父表主键!!!
            *****/
            $form->hidden('site_id');
            $form->hidden('supp_id', '服务机构')->value($pid);

            /****
            ***** 其实下面的代码可以从对应的Controler拷贝过来,而且可以移除rules
            *****/
            $form->text('site_name','站点名称')
                 ->rules('required',
                        ['required'=>'必要字段站点名称不能为空!',
                         'unique'  =>'出现重复站点名称!']);

            $form->select('province', '省')
                 ->options(\App\Models\AreaCode::provinces()->pluck('area_name','area_id'))
                 ->load('city',admin_base_path('/api/cities'))
                 ->rules('required',['required'=>'必要字段不能为空!']);

            $form->select('city', '市')
                 ->load('area',admin_base_path('/api/areas'))
                 ->rules('required',['required'=>'必要字段不能为空!'])
                 ->options(function($id)
                    {
                        return \App\Models\AreaCode::cities($id)->pluck('area_name','area_id');
                    });

            $form->select('area', '区')
                 ->rules('required',['required'=>'必要字段不能为空!'])
                 ->options(function($id)
                    {
                        return \App\Models\AreaCode::areas($id)->pluck('area_name','area_id');
                    });

            $form->text('site_addr','站点地址')
                 ->rules('required',['required'=>'必要字段不能为空!']);

            $form->select('contact_id','机构联系人')
                 ->options(\App\Models\Contact::contactInfo()->pluck('contact_info','contact_id'))
                 ->rules('required',['required'=>'必要字段机构联系人不能为空!',]);
        });
    }
robinhoo1973 commented 6 years ago

截两张图: image image

image