Browse Source

增加懒加载层级树Grid功能;

jqh 5 năm trước cách đây
mục cha
commit
9be5e9ff4a

+ 7 - 7
resources/views/tree/branch.blade.php

@@ -3,7 +3,7 @@
         {!! $branchCallback($branch) !!}
         <span class="pull-right dd-nodrag">
             @if($useEdit)
-                <a href="{{ $path }}/{{ $branch[$keyName] }}/edit"><i class="ti-pencil-alt "></i>&nbsp;</a>
+            <a href="{{ $path }}/{{ $branch[$keyName] }}/edit"><i class="ti-pencil-alt "></i>&nbsp;</a>
             @endif
 
             @if($useQuickEdit)
@@ -11,15 +11,15 @@
             @endif
 
             @if($useDelete)
-                <a href="javascript:void(0);" data-url="{{ $path }}/{{ $branch[$keyName] }}" data-action="delete"><i class="ti-trash "></i></a>
+            <a href="javascript:void(0);" data-url="{{ $path }}/{{ $branch[$keyName] }}" data-action="delete"><i class="ti-trash "></i></a>
             @endif
         </span>
     </div>
     @if(isset($branch['children']))
-        <ol class="dd-list">
-            @foreach($branch['children'] as $branch)
-                @include($branchView, $branch)
-            @endforeach
-        </ol>
+    <ol class="dd-list">
+        @foreach($branch['children'] as $branch)
+            @include($branchView, $branch)
+        @endforeach
+    </ol>
     @endif
 </li>

+ 1 - 1
src/Controllers/PermissionController.php

@@ -176,8 +176,8 @@ class PermissionController extends Controller
         $grid = new Grid(new Permission());
 
         $grid->id('ID')->bold()->sortable();
+        $grid->name->tree();
         $grid->slug->label('primary');
-        $grid->name;
 
         $grid->http_path->display(function ($path) {
             if (! $path) {

+ 1 - 1
src/Controllers/UserController.php

@@ -101,7 +101,7 @@ class UserController extends Controller
                 ->if(function () {
                     return ! empty($this->roles);
                 })
-                ->tree(function (Grid\Displayers\Tree $tree) use (&$nodes, $roleModel) {
+                ->showTreeInDialog(function (Grid\Displayers\DialogTree $tree) use (&$nodes, $roleModel) {
                     $tree->nodes($nodes);
 
                     foreach (array_column($this->roles, 'slug') as $slug) {

+ 21 - 21
src/Grid/Column.php

@@ -31,7 +31,7 @@ use Illuminate\Support\Str;
  * @method $this table($titles = [])
  * @method $this select($options = [])
  * @method $this modal($title = '', \Closure $callback = null)
- * @method $this tree($callbackOrNodes = null)
+ * @method $this showTreeInDialog($callbackOrNodes = null)
  * @method $this qrcode($formatter = null, $width = 150, $height = 150)
  * @method $this downloadable($server = '', $disk = null)
  * @method $this copyable()
@@ -69,26 +69,26 @@ class Column
      * @var array
      */
     protected static $displayers = [
-        'editable'     => Displayers\Editable::class,
-        'switch'       => Displayers\SwitchDisplay::class,
-        'switchGroup'  => Displayers\SwitchGroup::class,
-        'select'       => Displayers\Select::class,
-        'image'        => Displayers\Image::class,
-        'label'        => Displayers\Label::class,
-        'button'       => Displayers\Button::class,
-        'link'         => Displayers\Link::class,
-        'badge'        => Displayers\Badge::class,
-        'progressBar'  => Displayers\ProgressBar::class,
-        'radio'        => Displayers\Radio::class,
-        'checkbox'     => Displayers\Checkbox::class,
-        'table'        => Displayers\Table::class,
-        'expand'       => Displayers\Expand::class,
-        'modal'        => Displayers\Modal::class,
-        'tree'         => Displayers\Tree::class,
-        'qrcode'       => Displayers\QRCode::class,
-        'downloadable' => Displayers\Downloadable::class,
-        'copyable'     => Displayers\Copyable::class,
-        'orderable'    => Displayers\Orderable::class,
+        'editable'         => Displayers\Editable::class,
+        'switch'           => Displayers\SwitchDisplay::class,
+        'switchGroup'      => Displayers\SwitchGroup::class,
+        'select'           => Displayers\Select::class,
+        'image'            => Displayers\Image::class,
+        'label'            => Displayers\Label::class,
+        'button'           => Displayers\Button::class,
+        'link'             => Displayers\Link::class,
+        'badge'            => Displayers\Badge::class,
+        'progressBar'      => Displayers\ProgressBar::class,
+        'radio'            => Displayers\Radio::class,
+        'checkbox'         => Displayers\Checkbox::class,
+        'table'            => Displayers\Table::class,
+        'expand'           => Displayers\Expand::class,
+        'modal'            => Displayers\Modal::class,
+        'showTreeInDialog' => Displayers\DialogTree::class,
+        'qrcode'           => Displayers\QRCode::class,
+        'downloadable'     => Displayers\Downloadable::class,
+        'copyable'         => Displayers\Copyable::class,
+        'orderable'        => Displayers\Orderable::class,
     ];
 
     /**

+ 30 - 0
src/Grid/Column/HasDisplayers.php

@@ -2,6 +2,7 @@
 
 namespace Dcat\Admin\Grid\Column;
 
+use Dcat\Admin\Grid;
 use Dcat\Admin\Grid\Column;
 use Dcat\Admin\Grid\Displayers\AbstractDisplayer;
 use Dcat\Admin\Support\Helper;
@@ -9,6 +10,9 @@ use Illuminate\Contracts\Support\Arrayable;
 use Illuminate\Support\Arr;
 use Illuminate\Support\Collection;
 
+/**
+ * @property Grid $grid
+ */
 trait HasDisplayers
 {
     /**
@@ -222,4 +226,30 @@ trait HasDisplayers
     {
         return $this->display('');
     }
+
+    /**
+     * Show children of current node.
+     *
+     * @param bool $showAll
+     * @param bool $sortable
+     *
+     * @return $this
+     */
+    public function tree(bool $showAll = false, bool $sortable = true)
+    {
+        $this->grid->model()->enableTree($showAll, $sortable);
+
+        $this->grid->fetching(function () use ($showAll) {
+            if ($this->grid->model()->getParentIdFromRequest()) {
+                $this->grid->disableFilter();
+                $this->grid->disableToolbar();
+
+                if ($showAll) {
+                    $this->grid->disablePagination();
+                }
+            }
+        });
+
+        return $this->displayUsing(Grid\Displayers\Tree::class);
+    }
 }

+ 151 - 0
src/Grid/Concerns/HasTree.php

@@ -0,0 +1,151 @@
+<?php
+
+namespace Dcat\Admin\Grid\Concerns;
+
+use Dcat\Admin\Admin;
+use Illuminate\Support\Collection;
+
+trait HasTree
+{
+    /**
+     * @var string
+     */
+    protected $parentIdQueryName = '__parent_id__';
+
+    /**
+     * @var string
+     */
+    protected $levelQueryName = '__level__';
+
+    /**
+     * @var bool
+     */
+    protected $showAllChildrenNodes = false;
+
+    /**
+     * @param bool $showAll
+     * @param bool $sortable
+     *
+     * @return void
+     */
+    public function enableTree(bool $showAll, bool $sortable)
+    {
+        $this->showAllChildrenNodes = $showAll;
+
+        $this->grid->fetching(function () use ($sortable) {
+            $parentId = $this->getParentIdFromRequest();
+
+            if (
+                $sortable
+                && ! $this->findQueryByMethod('orderBy')
+                && ! $this->findQueryByMethod('orderByDesc')
+                && ($orderColumn = $this->repository->getOrderColumn())
+            ) {
+                $this->orderBy($orderColumn);
+            }
+
+            $this->where($this->repository->getParentColumn(), $parentId);
+
+            $this->setPageName(
+                $this->getChildrenPageName($parentId)
+            );
+        });
+
+        $this->collection(function (Collection $collection) {
+            if (! $this->getParentIdFromRequest()) {
+                return $collection;
+            }
+
+            if ($collection->isEmpty()) {
+                abort(404);
+            }
+
+            if ($this->grid()->allowPagination()) {
+                $nextPage = $this->getCurrentChildrenPage() + 1;
+                Admin::html(
+                    <<<HTML
+<next-page class="hidden">{$nextPage}</next-page>
+<last-page class="hidden">{$this->paginator()->lastPage()}</last-page>
+HTML
+                );
+            }
+
+            return $collection;
+        });
+    }
+
+    /**
+     * @return mixed
+     */
+    public function getChildrenQueryNamePrefix()
+    {
+        return $this->grid->getName();
+    }
+
+    /**
+     * @param mixed $parentId
+     *
+     * @return string
+     */
+    public function getChildrenPageName($parentId)
+    {
+        return $this->getChildrenQueryNamePrefix().'children_page_'.$parentId;
+    }
+
+    /**
+     * @return int
+     */
+    public function getCurrentChildrenPage()
+    {
+        return $this->request->get(
+            $this->getChildrenPageName(
+                $this->getParentIdFromRequest()
+            )
+        ) ?: 1;
+    }
+
+    /**
+     * @return string
+     */
+    public function getParentIdQueryName()
+    {
+        return $this->getChildrenQueryNamePrefix().$this->parentIdQueryName;
+    }
+
+    /**
+     * @return int
+     */
+    public function getParentIdFromRequest()
+    {
+        return $this->request->get(
+            $this->getParentIdQueryName()
+        ) ?: 0;
+    }
+
+    /**
+     * @return string
+     */
+    public function getLevelQueryName()
+    {
+        return $this->getChildrenQueryNamePrefix().$this->levelQueryName;
+    }
+
+
+    /**
+     * @return int
+     */
+    public function getLevelFromRequest()
+    {
+        return $this->request->get(
+            $this->getLevelQueryName()
+        ) ?: 0;
+    }
+
+    /**
+     * @return bool
+     */
+    public function showAllChildrenNodes()
+    {
+        return $this->showAllChildrenNodes;
+    }
+}

+ 1 - 1
src/Grid/Displayers/Checkbox.php

@@ -62,7 +62,7 @@ EOT;
         return <<<JS
 (function () {
     var f;
-    $('form.{$this->elementClass()}').on('submit', function () {
+    $('form.{$this->elementClass()}').off('submit').on('submit', function () {
         var values = $(this).find('input:checkbox:checked').map(function (_, el) {
             return $(el).val();
         }).get(), btn = $(this).find('[type="submit"]');

+ 1 - 1
src/Grid/Displayers/Copyable.php

@@ -14,7 +14,7 @@ class Copyable extends AbstractDisplayer
     protected function addScript()
     {
         $script = <<<'JS'
-$('.grid-column-copyable').click(function (e) {
+$('.grid-column-copyable').off('click').click(function (e) {
     
     var content = $(this).data('content');
     

+ 275 - 0
src/Grid/Displayers/DialogTree.php

@@ -0,0 +1,275 @@
+<?php
+
+namespace Dcat\Admin\Grid\Displayers;
+
+use Dcat\Admin\Admin;
+use Dcat\Admin\Support\Helper;
+use Illuminate\Contracts\Support\Arrayable;
+
+class DialogTree extends AbstractDisplayer
+{
+    protected $url;
+
+    protected $title;
+
+    protected $area = ['650px', '600px'];
+
+    protected $options = [
+        'plugins' => ['checkbox', 'types'],
+        'core'    => [
+            'check_callback' => true,
+
+            'themes' => [
+                'name'       => 'proton',
+                'responsive' => true,
+            ],
+        ],
+        'checkbox' => [
+            'keep_selected_style' => false,
+        ],
+        'types' => [
+            'default' => [
+                'icon' => false,
+            ],
+        ],
+    ];
+
+    /**
+     * @var array
+     */
+    protected $columnNames = [
+        'id'     => 'id',
+        'text'   => 'name',
+        'parent' => 'parent_id',
+    ];
+
+    protected $nodes = [];
+
+    protected $checkedAll;
+
+    /**
+     * @param array $data exp:
+     *                    {
+     *                    "id": "1",
+     *                    "parent": "#",
+     *                    "text": "Dashboard",
+     *                    // "state": {"selected": true}
+     *                    }
+     * @param array $data
+     *
+     * @return $this
+     */
+    public function nodes($data)
+    {
+        if ($data instanceof Arrayable) {
+            $data = $data->toArray();
+        }
+
+        $this->nodes = &$data;
+
+        return $this;
+    }
+
+    public function url(string $source)
+    {
+        $this->url = admin_url($source);
+
+        return $this;
+    }
+
+    public function checkedAll()
+    {
+        $this->checkedAll = true;
+
+        return $this;
+    }
+
+    /**
+     * @param array $options
+     *
+     * @return $this
+     */
+    public function options($options = [])
+    {
+        if ($options instanceof Arrayable) {
+            $options = $options->toArray();
+        }
+
+        $this->options = array_merge($this->options, $options);
+
+        return $this;
+    }
+
+    public function title($title)
+    {
+        $this->title = $title;
+
+        return $this;
+    }
+
+    /**
+     * @param string $width
+     * @param string $height
+     *
+     * @return $this
+     */
+    public function area(string $width, string $height)
+    {
+        $this->area = [$width, $height];
+
+        return $this;
+    }
+
+    /**
+     * @param string $idColumn
+     * @param string $textColumn
+     * @param string $parentColumn
+     *
+     * @return $this
+     */
+    public function columnNames(string $idColumn = 'id', string $textColumn = 'name', string $parentColumn = 'parent_id')
+    {
+        $this->columnNames['id'] = $idColumn;
+        $this->columnNames['text'] = $textColumn;
+        $this->columnNames['parent'] = $parentColumn;
+
+        return $this;
+    }
+
+    public function display($callbackOrNodes = null)
+    {
+        if (is_array($callbackOrNodes) || $callbackOrNodes instanceof Arrayable) {
+            $this->nodes($callbackOrNodes);
+        } elseif ($callbackOrNodes instanceof \Closure) {
+            $callbackOrNodes->call($this->row, $this);
+        }
+
+        $btn = $this->trans('view');
+
+        $this->setupScript();
+
+        $val = $this->format($this->value);
+
+        return <<<EOF
+<a href="javascript:void(0)" class="{$this->getSelectorPrefix()}-open-tree" data-checked="{$this->checkedAll}" data-val="{$val}"><i class='ti-layout-list-post'></i> $btn</a>
+EOF;
+    }
+
+    protected function format($val)
+    {
+        return implode(',', Helper::array($val, true));
+    }
+
+    protected function getSelectorPrefix()
+    {
+        return $this->grid->getName().'_'.$this->column->getName();
+    }
+
+    protected function setupScript()
+    {
+        $title = $this->title ?: $this->column->getLabel();
+
+        $area = json_encode($this->area);
+        $opts = json_encode($this->options);
+        $nodes = json_encode($this->nodes);
+
+        Admin::script(
+            <<<JS
+$('.{$this->getSelectorPrefix()}-open-tree').off('click').click(function () {
+    var tpl = '<div class="jstree-wrapper" style="border:0"><div class="_tree" style="margin-top:10px"></div></div>', 
+        opts = $opts,
+        url = '{$this->url}',
+        t = $(this),
+        val = t.data('val'),
+        ckall = t.data('checked'),
+        idx,
+        requesting;
+
+    val = val ? String(val).split(',') : [];
+        
+    if (url) {
+        if (requesting) return;
+        requesting = 1;
+        
+        t.button('loading');
+        $.getJSON(url, {_token: LA.token, value: val}, function (resp) {
+             requesting = 0;
+             t.button('reset');
+             
+             if (!resp.status) {
+                return LA.error(resp.message || '系统繁忙,请稍后再试');
+             }
+             
+             build(resp.value);
+        });
+    } else {
+        build(val);
+    }    
+        
+    function build(val) {
+        opts.core.data = formatNodes(val, $nodes);    
+    
+        idx = layer.open({
+            type: 1,
+            area: $area,
+            content: tpl,
+            title: '{$title}',
+            success: function (a, idx) {
+                var tree = $('#layui-layer'+idx).find('._tree');
+                
+                tree.on("loaded.jstree", function () {
+                    tree.jstree('open_all');
+                }).jstree(opts);
+            }
+        });
+        
+        $(document).one('pjax:complete', function () { 
+            layer.close(idx);
+        });
+    }
+    
+    function formatNodes(value, all) {
+        var idColumn = '{$this->columnNames['id']}', 
+           textColumn = '{$this->columnNames['text']}', 
+           parentColumn = '{$this->columnNames['parent']}';
+        var parentIds = [], nodes = [], i, v, parentId;
+
+        for (i in all) {
+            v = all[i];
+            if (!v[idColumn]) continue;
+
+            parentId = v[parentColumn] || '#';
+            if (!parentId) {
+                parentId = '#';
+            } else {
+                parentIds.push(parentId);
+            }
+
+            v['state'] = {'disabled': true};
+
+            if (ckall || (value && LA.arr.in(value, v[idColumn]))) {
+                v['state']['selected'] = true;
+            }
+
+            nodes.push({
+                'id'     : v[idColumn],
+                'text'   : v[textColumn] || null,
+                'parent' : parentId,
+                'state'  : v['state'],
+            });
+        }
+       
+        return nodes;
+    }
+    
+});
+JS
+        );
+    }
+
+    protected function collectAssets()
+    {
+        Admin::css('vendor/dcat-admin/jstree-theme/themes/proton/style.min.css');
+        Admin::collectComponentAssets('jstree');
+    }
+}

+ 128 - 11
src/Grid/Displayers/Orderable.php

@@ -37,19 +37,136 @@ EOT;
     protected function script()
     {
         return <<<JS
-
-$('.{$this->grid->rowName()}-orderable').on('click', function() {
-
-    var key = $(this).data('id');
-    var direction = $(this).data('direction');
-
-    $.post('{$this->resource()}/' + key, {_method:'PUT', _token:LA.token, _orderable:direction}, function(data){
-        if (data.status) {
-            LA.reload();
-            LA.success(data.message);
+(function () {
+    var req = 0;
+    
+    $('.{$this->grid->rowName()}-orderable').off('click').on('click', function() {
+        if (req) return;
+        
+        var key = $(this).data('id'),
+            direction = $(this).data('direction'),
+            row = $(this).closest('tr'),
+            prevAll = row.prevAll(),
+            nextAll = row.nextAll(),
+            prev = row.prevAll('tr').first(),
+            next = row.nextAll('tr').first(),
+            level = getLevel(row);
+        
+        req = 1;
+        LA.loading();
+        
+        function swapable(_o) {
+            if (
+                _o
+                && _o.length 
+                && level === getLevel(_o)
+            ) {
+                return true
+            }
+        }
+        
+        function isTr(v) {
+            return $(v).prop('tagName').toLocaleLowerCase() === 'tr'
+        }
+        
+        function getLevel(v) {
+            return parseInt($(v).data('level') || 0);
         }
+        
+        function isChildren(parent, child) {
+            return getLevel(child) > getLevel(parent);
+        }
+        
+        function getChildren(all, parent) {
+            var arr = [], isBreak = false, firstTr;
+            all.each(function (_, v) {
+                 // 过滤非tr标签
+                 if (! isTr(v) || isBreak) return;
+                
+                 firstTr || (firstTr = $(v));
+          
+                 // 非连续的子节点
+                 if (firstTr && ! isChildren(parent, firstTr)) {
+                     return;
+                 }
+                
+                 if (isChildren(parent, v)) {
+                     arr.push(v)
+                 } else {
+                     isBreak = true;
+                 }
+            });
+            
+            return arr;
+        }
+        
+        function sibling(all) {
+            var next;
+            
+            all.each(function (_, v) {
+                 if (getLevel(v) === level && ! next && isTr(v)) {
+                     next = $(v);
+                 }
+            });
+            
+            return next;
+        }
+        
+        $.ajax({
+            type: 'POST',
+            url: '{$this->resource()}/' + key,
+            data: {_method:'PUT', _token:LA.token, _orderable:direction},
+            success: function(data){
+                LA.loading(false);
+                req = 0;
+                if (data.status) {
+                    LA.success(data.message);
+                    
+                    if (direction) {
+                        var prevRow = sibling(prevAll);
+                        if (swapable(prevRow) && prev.length && getLevel(prev) >= level) {
+                            prevRow.before(row);
+                            
+                            // 把所有子节点上移
+                            getChildren(nextAll, row).forEach(function (v) {
+                                prevRow.before(v)
+                            });
+                        }
+                    } else {
+                        var nextRow = sibling(nextAll),
+                            nextRowChildren = nextRow ? getChildren(nextRow.nextAll(), nextRow) : [];
+                        
+                        if (swapable(nextRow) && next.length && getLevel(next) >= level) {
+                            nextAll = row.nextAll();
+
+                            if (nextRowChildren.length) {
+                                nextRow = $(nextRowChildren.pop())
+                            }
+                            
+                             // 把所有子节点下移
+                             var all = [];
+                            getChildren(nextAll, row).forEach(function (v) {
+                                all.unshift(v)
+                            });
+                            
+                            all.forEach(function(v) {
+                                nextRow.after(v)
+                            });
+                            
+                            nextRow.after(row);
+                        }
+                    }
+                }
+            },
+            error: function (a, b, c) {
+                req = 0;
+                LA.loading(false);
+                LA.ajaxError(a, b, c)
+            }
+        });
+    
     });
-});
+})()
 JS;
     }
 }

+ 1 - 1
src/Grid/Displayers/Select.php

@@ -18,7 +18,7 @@ class Select extends AbstractDisplayer
 
         $script = <<<JS
 
-$('.$class').select2().on('change', function(){
+$('.$class').off('change').select2().on('change', function(){
     var pk = $(this).data('key');
     var value = $(this).val();
     LA.NP.start();

+ 1 - 1
src/Grid/Displayers/SwitchDisplay.php

@@ -93,7 +93,7 @@ EOF;
         })
     } 
     init();
-    swt.change(function(e) {
+    swt.off('change').change(function(e) {
         var t = $(this), id = t.data('key'), checked = t.is(':checked'), name = t.attr('name'), data = {
             _token: LA.token,
             _method: 'PUT'

+ 1 - 1
src/Grid/Displayers/SwitchGroup.php

@@ -58,7 +58,7 @@ class SwitchGroup extends SwitchDisplay
         })
     } 
     init();
-    swt.change(function(e) {
+    swt.off('change').change(function(e) {
         var t = $(this), id=t.data('key'),checked = t.is(':checked'), name = t.attr('name'), data = {
             _token: LA.token,
             _method: 'PUT'

+ 125 - 242
src/Grid/Displayers/Tree.php

@@ -3,273 +3,156 @@
 namespace Dcat\Admin\Grid\Displayers;
 
 use Dcat\Admin\Admin;
-use Dcat\Admin\Support\Helper;
-use Illuminate\Contracts\Support\Arrayable;
 
 class Tree extends AbstractDisplayer
 {
-    protected $url;
-
-    protected $title;
-
-    protected $area = ['650px', '600px'];
-
-    protected $options = [
-        'plugins' => ['checkbox', 'types'],
-        'core'    => [
-            'check_callback' => true,
-
-            'themes' => [
-                'name'       => 'proton',
-                'responsive' => true,
-            ],
-        ],
-        'checkbox' => [
-            'keep_selected_style' => false,
-        ],
-        'types' => [
-            'default' => [
-                'icon' => false,
-            ],
-        ],
-    ];
-
-    /**
-     * @var array
-     */
-    protected $columnNames = [
-        'id'     => 'id',
-        'text'   => 'name',
-        'parent' => 'parent_id',
-    ];
-
-    protected $nodes = [];
-
-    protected $checkedAll;
-
-    /**
-     * @param array $data exp:
-     *                    {
-     *                    "id": "1",
-     *                    "parent": "#",
-     *                    "text": "Dashboard",
-     *                    // "state": {"selected": true}
-     *                    }
-     * @param array $data
-     *
-     * @return $this
-     */
-    public function nodes($data)
-    {
-        if ($data instanceof Arrayable) {
-            $data = $data->toArray();
-        }
-
-        $this->nodes = &$data;
-
-        return $this;
-    }
-
-    public function url(string $source)
-    {
-        $this->url = admin_url($source);
-
-        return $this;
-    }
-
-    public function checkedAll()
-    {
-        $this->checkedAll = true;
-
-        return $this;
-    }
-
-    /**
-     * @param array $options
-     *
-     * @return $this
-     */
-    public function options($options = [])
+    public function display()
     {
-        if ($options instanceof Arrayable) {
-            $options = $options->toArray();
-        }
-
-        $this->options = array_merge($this->options, $options);
-
-        return $this;
-    }
-
-    public function title($title)
-    {
-        $this->title = $title;
+        $this->setupScript();
 
-        return $this;
-    }
+        $key = $this->key();
+        $tableId = $this->grid->tableId();
 
-    /**
-     * @param string $width
-     * @param string $height
-     *
-     * @return $this
-     */
-    public function area(string $width, string $height)
-    {
-        $this->area = [$width, $height];
+        $level = $this->grid->model()->getLevelFromRequest();
+        $indents = str_repeat(' &nbsp; &nbsp; &nbsp; &nbsp; ', $level);
 
-        return $this;
+        return <<<EOT
+<a href="javascript:void(0)" class="{$tableId}-grid-load-children" data-level="{$level}" data-inserted="0" data-key="{$key}">
+   {$indents}<i class="fa fa-angle-right"></i> &nbsp; {$this->value}
+</a>
+EOT;
     }
 
-    /**
-     * @param string $idColumn
-     * @param string $textColumn
-     * @param string $parentColumn
-     *
-     * @return $this
-     */
-    public function columnNames(string $idColumn = 'id', string $textColumn = 'name', string $parentColumn = 'parent_id')
+    protected function showNextPage()
     {
-        $this->columnNames['id'] = $idColumn;
-        $this->columnNames['text'] = $textColumn;
-        $this->columnNames['parent'] = $parentColumn;
-
-        return $this;
-    }
+        $model = $this->grid->model();
 
-    public function display($callbackOrNodes = null)
-    {
-        if (is_array($callbackOrNodes) || $callbackOrNodes instanceof Arrayable) {
-            $this->nodes($callbackOrNodes);
-        } elseif ($callbackOrNodes instanceof \Closure) {
-            $callbackOrNodes->call($this->row, $this);
+        $showNextPage = $this->grid->allowPagination();
+        if (! $model->showAllChildrenNodes() && $showNextPage) {
+            $showNextPage =
+                $model->getCurrentChildrenPage() < $model->paginator()->lastPage()
+                && $model->buildData()->count() == $model->getPerPage();
         }
 
-        $btn = $this->trans('view');
-
-        $this->setupScript();
-
-        $val = $this->format($this->value);
-
-        return <<<EOF
-<a href="javascript:void(0)" class="{$this->getSelectorPrefix()}-open-tree" data-checked="{$this->checkedAll}" data-val="{$val}"><i class='ti-layout-list-post'></i> $btn</a>
-EOF;
-    }
-
-    protected function format($val)
-    {
-        return implode(',', Helper::array($val, true));
-    }
-
-    protected function getSelectorPrefix()
-    {
-        return $this->grid->getName().'_'.$this->column->getName();
+        return $showNextPage;
     }
 
     protected function setupScript()
     {
-        $title = $this->title ?: $this->column->getLabel();
+        // 分页问题
+        $url = request()->fullUrl();
+        $tableId = $this->grid->tableId();
 
-        $area = json_encode($this->area);
-        $opts = json_encode($this->options);
-        $nodes = json_encode($this->nodes);
+        $model = $this->grid->model();
 
-        Admin::script(
-            <<<JS
-$('.{$this->getSelectorPrefix()}-open-tree').click(function () {
-    var tpl = '<div class="jstree-wrapper" style="border:0"><div class="_tree" style="margin-top:10px"></div></div>', 
-        opts = $opts,
-        url = '{$this->url}',
-        t = $(this),
-        val = t.data('val'),
-        ckall = t.data('checked'),
-        idx,
-        requesting;
+        // 是否显示下一页按钮
+        $pageName = $model->getChildrenPageName(':key');
+        $perPage = $model->getPerPage();
+        $showNextPage = $model->showAllChildrenNodes() ? 'false' : 'true';
 
-    val = val ? String(val).split(',') : [];
-        
-    if (url) {
-        if (requesting) return;
-        requesting = 1;
+        $script = <<<JS
+(function () {
+    var req = 0;
+    
+    $('.{$tableId}-grid-load-children').off('click').click(function () {
+        if (req) {
+            return;
+        }
         
-        t.button('loading');
-        $.getJSON(url, {_token: LA.token, value: val}, function (resp) {
-             requesting = 0;
-             t.button('reset');
-             
-             if (!resp.status) {
-                return LA.error(resp.message || '系统繁忙,请稍后再试');
-             }
-             
-             build(resp.value);
-        });
-    } else {
-        build(val);
-    }    
+        var key = $(this).data('key'),
+            level = $(this).data('level'),
+            trClass = '{$tableId}-tr-'+key,
+            data = {
+                    _token: LA.token, 
+                    '{$model->getParentIdQueryName()}': key, 
+                    '{$model->getLevelQueryName()}': level + 1, 
+                };
         
-    function build(val) {
-        opts.core.data = formatNodes(val, $nodes);    
+         $('.'+trClass).toggle();
     
-        idx = layer.open({
-            type: 1,
-            area: $area,
-            content: tpl,
-            title: '{$title}',
-            success: function (a, idx) {
-                var tree = $('#layui-layer'+idx).find('._tree');
-                
-                tree.on("loaded.jstree", function () {
-                    tree.jstree('open_all');
-                }).jstree(opts);
-            }
-        });
+        if ($(this).data('inserted') == '0') {
+            var row = $(this).closest('tr');
+            request(1);
+            $(this).data('inserted', 1);
+        }
+       
+        $("i", this).toggleClass("fa-angle-right fa-angle-down");
         
-        $(document).one('pjax:complete', function () { 
-            layer.close(idx);
-        });
-    }
-    
-    function formatNodes(value, all) {
-        var idColumn = '{$this->columnNames['id']}', 
-           textColumn = '{$this->columnNames['text']}', 
-           parentColumn = '{$this->columnNames['parent']}';
-        var parentIds = [], nodes = [], i, v, parentId;
-
-        for (i in all) {
-            v = all[i];
-            if (!v[idColumn]) continue;
-
-            parentId = v[parentColumn] || '#';
-            if (!parentId) {
-                parentId = '#';
-            } else {
-                parentIds.push(parentId);
+        function request(page, after) {
+            if (req) {
+                return;
             }
-
-            v['state'] = {'disabled': true};
-
-            if (ckall || (value && LA.arr.in(value, v[idColumn]))) {
-                v['state']['selected'] = true;
-            }
-
-            nodes.push({
-                'id'     : v[idColumn],
-                'text'   : v[textColumn] || null,
-                'parent' : parentId,
-                'state'  : v['state'],
+            req = 1;
+             LA.loading();
+             
+             data['{$pageName}'.replace(':key', key)] = page;
+           
+            $.ajax({
+                url: '$url',
+                type: 'GET',
+                data: data,
+                cache: false,
+                headers: {'X-PJAX': true},
+                success: function (resp) {
+                    after && after();
+                    LA.loading(false);
+                    req = 0;
+                    
+                    // 获取最后一行
+                    var children = $('.'+trClass);
+                    row = children.length ? children.last() : row;
+                    
+                    var _body = $('<div>'+resp+'</div>'),
+                        _tbody = _body.find('#{$tableId} tbody'),
+                        lastPage = _body.find('last-page').text(),
+                        nextPage = _body.find('next-page').text();
+  
+                    // 标记子节点行
+                    _tbody.find('tr').each(function (_, v) {
+                        $(v).addClass(trClass);
+                        $(v).attr('data-level', level + 1) 
+                    });
+                    
+                    var html = _tbody.html(),
+                        icon = '<svg style="fill:currentColor" t="1582877365167" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="32874" width="24" height="24"><path d="M162.8 515m-98.3 0a98.3 98.3 0 1 0 196.6 0 98.3 98.3 0 1 0-196.6 0Z" p-id="32875"></path><path d="M511.9 515m-98.3 0a98.3 98.3 0 1 0 196.6 0 98.3 98.3 0 1 0-196.6 0Z" p-id="32876"></path><path d="M762.8 515a98.3 98.3 0 1 0 196.6 0 98.3 98.3 0 1 0-196.6 0Z" p-id="32877"></path></svg>';
+                    
+                    if ({$showNextPage} && _tbody.find('tr').length == '{$perPage}' && lastPage >= page) {
+                        // 加载更多
+                        html += "<tr data-page='"+nextPage+"' class='{$tableId}-load-next-"+key+" "
+                            +trClass+"'><td colspan='"+(row.find('td').length)
+                            +"' align='center' style='cursor: pointer'> <a>"+icon+"</a> </td></tr>";
+                    }
+                    
+                    // 附加子节点
+                    row.after(html);
+
+                     // 加载更多
+                    $('.{$tableId}-load-next-'+key).off('click').click(function () {
+                        var _t = $(this);
+                        request(_t.data('page'), function () {
+                            _t.remove();
+                        });
+                    });
+                   
+                    // 附加子节点js脚本以及触发子节点js脚本执行
+                    _body.find('script').each(function (_, v) {
+                        row.after(v);
+                    });
+                    $(document).trigger('pjax:script')
+                },
+                error:function(a, b, c){
+                    after && after();
+                    LA.loading(false);
+                    req = 0;
+                    if (a.status != 404) {
+                        LA.ajaxError(a, b, c)
+                    }
+                }
             });
-        }
-       
-        return nodes;
-    }
-    
-});
-JS
-        );
-    }
-
-    protected function collectAssets()
-    {
-        Admin::css('vendor/dcat-admin/jstree-theme/themes/proton/style.min.css');
-        Admin::collectComponentAssets('jstree');
+        }   
+    });
+})();
+JS;
+        Admin::script($script);
     }
 }

+ 19 - 12
src/Grid/Model.php

@@ -21,6 +21,8 @@ use Illuminate\Support\Str;
  */
 class Model
 {
+    use Grid\Concerns\HasTree;
+
     /**
      * @var Request
      */
@@ -168,7 +170,7 @@ class Model
     }
 
     /**
-     * @return AbstractPaginator
+     * @return AbstractPaginator|LengthAwarePaginator
      */
     public function paginator(): AbstractPaginator
     {
@@ -374,6 +376,16 @@ class Model
     {
         if (is_null($this->data)) {
             $this->setData($this->fetch());
+
+            if ($this->collectionCallback) {
+                $data = $this->data;
+
+                foreach ($this->collectionCallback as $cb) {
+                    $data = call_user_func($cb, $data);
+                }
+
+                $this->setData($data);
+            }
         }
 
         return $toArray ? $this->data->toArray() : $this->data;
@@ -392,12 +404,6 @@ class Model
             return $this;
         }
 
-        if ($this->collectionCallback) {
-            foreach ($this->collectionCallback as $cb) {
-                $data = call_user_func($cb, $this->data);
-            }
-        }
-
         if ($data instanceof AbstractPaginator) {
             $this->setPaginator($data);
 
@@ -476,10 +482,6 @@ class Model
         $this->paginator = $paginator;
 
         $paginator->setPageName($this->pageName);
-
-        if ($paginator instanceof LengthAwarePaginator) {
-            $this->handleInvalidPage($paginator);
-        }
     }
 
     /**
@@ -589,7 +591,12 @@ class Model
             $this->perPage = (int) $perPage;
         }
 
-        return [$this->perPage, '*', $this->pageName, $this->getCurrentPage()];
+        return [
+            $this->perPage,
+            $this->repository->getGridColumns(),
+            $this->pageName,
+            $this->getCurrentPage(),
+        ];
     }
 
     /**

+ 2 - 1
src/Models/Permission.php

@@ -8,8 +8,9 @@ use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Http\Request;
 use Illuminate\Support\Str;
+use Spatie\EloquentSortable\Sortable;
 
-class Permission extends Model
+class Permission extends Model implements Sortable
 {
     use ModelTree {
         ModelTree::boot as treeBoot;

+ 85 - 3
src/Traits/ModelTree.php

@@ -9,14 +9,18 @@ use Illuminate\Database\Eloquent\Model;
 use Illuminate\Support\Arr;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Request;
+use Spatie\EloquentSortable\SortableTrait;
 
 /**
  * @property string $parentColumn
  * @property string $titleColumn
  * @property string $orderColumn
+ * @property array  $sortable
  */
 trait ModelTree
 {
+    use SortableTrait;
+
     /**
      * @var array
      */
@@ -60,7 +64,7 @@ trait ModelTree
      *
      * @param string $column
      */
-    public function setParentColumn($column)
+    public function setParentColumn(string $column)
     {
         $this->parentColumn = $column;
     }
@@ -80,7 +84,7 @@ trait ModelTree
      *
      * @param string $column
      */
-    public function setTitleColumn($column)
+    public function setTitleColumn(string $column)
     {
         $this->titleColumn = $column;
     }
@@ -100,11 +104,12 @@ trait ModelTree
      *
      * @param string $column
      */
-    public function setOrderColumn($column)
+    public function setOrderColumn(string $column)
     {
         $this->orderColumn = $column;
     }
 
+
     /**
      * Set query callback to model.
      *
@@ -212,6 +217,79 @@ trait ModelTree
         }
     }
 
+    protected function determineOrderColumnName()
+    {
+        return $this->getOrderColumn();
+    }
+
+    public function moveOrderDown()
+    {
+        $orderColumnName = $this->determineOrderColumnName();
+        $parentColumnName = $this->getParentColumn();
+
+        $swapWithModel = $this->buildSortQuery()->limit(1)
+            ->ordered()
+            ->where($orderColumnName, '>', $this->$orderColumnName)
+            ->where($parentColumnName, $this->$parentColumnName)
+            ->first();
+
+        if (! $swapWithModel) {
+            return $this;
+        }
+
+        return $this->swapOrderWithModel($swapWithModel);
+    }
+
+    public function moveOrderUp()
+    {
+        $orderColumnName = $this->determineOrderColumnName();
+        $parentColumnName = $this->getParentColumn();
+
+        $swapWithModel = $this->buildSortQuery()->limit(1)
+            ->ordered('desc')
+            ->where($orderColumnName, '<', $this->$orderColumnName)
+            ->where($parentColumnName, $this->$parentColumnName)
+            ->first();
+
+        if (! $swapWithModel) {
+            return $this;
+        }
+
+        return $this->swapOrderWithModel($swapWithModel);
+    }
+
+    public function moveToStart()
+    {
+        $parentColumnName = $this->getParentColumn();
+
+        $firstModel = $this->buildSortQuery()->limit(1)
+            ->ordered()
+            ->where($parentColumnName, $this->$parentColumnName)
+            ->first();
+
+        if ($firstModel->id === $this->id) {
+            return $this;
+        }
+
+        $orderColumnName = $this->determineOrderColumnName();
+
+        $this->$orderColumnName = $firstModel->$orderColumnName;
+        $this->save();
+
+        $this->buildSortQuery()->where($this->getKeyName(), '!=', $this->id)->increment($orderColumnName);
+
+        return $this;
+    }
+
+    public function getHighestOrderNumber(): int
+    {
+        $parentColumnName = $this->getParentColumn();
+
+        return (int) $this->buildSortQuery()
+            ->where($parentColumnName, $this->$parentColumnName)
+            ->max($this->determineOrderColumnName());
+    }
+
     /**
      * Get options for Select field in form.
      *
@@ -284,6 +362,10 @@ trait ModelTree
     {
         parent::boot();
 
+        if (! trait_exists('\Spatie\EloquentSortable\SortableTrait')) {
+            throw new \Exception('To use ModelTree, please install package [spatie/eloquent-sortable] first.');
+        }
+
         static::saving(function (Model $branch) {
             $parentColumn = $branch->getParentColumn();