filament v4 中 树形表格(tree table)的实现

AI摘要
本文分享了为Filament表格组件实现树形结构的解决方案。核心步骤包括:1. 模型添加parent_id、title、order字段并定义父子关系方法;2. 通过宏定义扩展表格支持treeView();3. 使用JavaScript控制展开/收缩交互。该方法通过外部控制实现树形功能,避免了直接修改组件。虽然存在图标状态不更新等局限,但可复用性强。适用于Filament v2,v3版本理论上也可使用。

树表格

filament 数据表格自身是不带tree结构的, 要自己去改造table原有组件支持tree 难度太大,通过一番研究,最终通过外部控制的方式实现了树表格。

先看效果

filament v4 中 树弄表格(tree table)的实现

filament v4 中 树弄表格(tree table)的实现

逻辑

当点击 设置的 tree-title 后 会展开他的下级子类,展示后,再次点击会收缩子类。

实现步骤

1. 让model支持tree 结构

必须有这三个字段,parent_id, title,order,
AdminMenuModel.php



# 加上以下函数
class AdminMenu extends Model{
      // 获取父菜单
    public function parent(): BelongsTo
    {
        return $this->belongsTo(AdminMenu::class, 'parent_id');
    }

    // 获取子菜单
    public function children(): HasMany
    {
        return $this->hasMany(AdminMenu::class, 'parent_id')->ordered()->visible();
    }

    public function childrenRecursive(): HasMany
    {
        return $this->children()->with('childrenRecursive');
    }

    // 获取菜单深度
    public function depth(): int
    {
        $depth  = 0;
        $parent = $this->parent;

        while ($parent) {
            $depth++;
            $parent = $parent->parent;
        }

        return $depth;
    }

    // 排序
    public function scopeOrdered(Builder $query): Builder
    {
        return $query->orderBy('order');
    }

    // 显示
    public function scopeVisible(Builder $query): Builder
    {
        return $query->where('show', true);
    }
}

2.定义树形表格宏,让表格支持 treeView() 方法

app/Providers/AppServiceProvider.php

use  Closure;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Filament\Tables\Table;
use Filament\Actions\Action;
use Filament\Support\Enums\IconSize;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Support\Assets\Js;
use Filament\Support\Facades\FilamentAsset;

// 树形表格宏定义
        Table::macro('treeView', function (array $options = []) {
            /** @var Table $this */
            $options = array_merge([
                'parentColumn'       => 'parent_id',
                'rootValues'         => [0, '0', null, -1, '-1'],
                'orderColumn'        => 'order',
                'keyColumn'          => null,
                'reorderable'        => true,
                'hiddenClass'        => 'hidden',
                'recordVisibleUsing' => null,
                'isRootUsing'        => null,
                'reorderLabels'      => [
                    'enable'  => '启用重新排序',
                    'disable' => '禁用重新排序',
                ],
            ], $options);

            $isRecordVisible = function ($record) use ($options): bool {
                if ($options['recordVisibleUsing'] instanceof Closure) {
                    return (bool) value($options['recordVisibleUsing'], $record, $options);
                }

                if ($options['isRootUsing'] instanceof Closure) {
                    return (bool) value($options['isRootUsing'], $record, $options);
                }

                $parentValue = data_get($record, $options['parentColumn']);

                return in_array($parentValue, Arr::wrap($options['rootValues']), true);
            };

            $this->paginated(false)
                ->recordUrl(false)
                ->recordClasses(function ($record) use ($options, $isRecordVisible) {
                    $rid = 'pr-'.$record->parent_id;
                    return $isRecordVisible($record) ? '' : $rid.' '.$options['hiddenClass'];
                });

            if ($options['reorderable']) {
                $this->reorderable($options['orderColumn'])
                    ->reorderRecordsTriggerAction(
                        fn (Action $action, bool $isReordering) => $action
                            ->button()
                            ->label(
                                $isReordering
                                    ? $options['reorderLabels']['disable']
                                    : $options['reorderLabels']['enable'],
                            ),
                    );
            }

            $this->records(function () use ($options) {
                /** @var Table $this */
                $query = $this->getLivewire()->getFilteredSortedTableQuery();

                if (! $query) {
                    return collect();
                }

                $records = $query->get();

                if ($records->isEmpty()) {
                    return $records;
                }

                $keyColumn    = $options['keyColumn'] ?? $query->getModel()->getKeyName();
                $parentColumn = $options['parentColumn'];
                $orderColumn  = $options['orderColumn'];
                $rootValues   = Arr::wrap($options['rootValues']);

                $grouped   = $records->groupBy(fn ($record) => data_get($record, $parentColumn));
                $visited   = [];
                $flattened = collect();

                $traverse = function ($parentValue) use (&$traverse, $grouped, &$flattened, &$visited, $keyColumn, $orderColumn): void {
                    $children = $grouped->get($parentValue);

                    if (! $children) {
                        return;
                    }

                    $children = $children
                        ->sortBy([[$orderColumn, 'asc'], [$keyColumn, 'asc']])
                        ->values();

                    foreach ($children as $child) {
                        $recordKey = data_get($child, $keyColumn);

                        if ($recordKey === null) {
                            continue;
                        }

                        $flattened->push($child);
                        $visited[$recordKey] = true;

                        $traverse($recordKey);
                    }
                };

                foreach ($rootValues as $rootValue) {
                    $traverse($rootValue);
                }

                if ($records->count() !== count($visited)) {
                    $records
                        ->filter(fn ($record) => ! array_key_exists(data_get($record, $keyColumn), $visited))
                        ->sortBy([[$parentColumn, 'asc'], [$orderColumn, 'asc'], [$keyColumn, 'asc']])
                        ->each(function ($record) use (&$traverse, &$flattened, $keyColumn, &$visited): void {
                            $recordKey = data_get($record, $keyColumn);

                            if ($recordKey === null || array_key_exists($recordKey, $visited)) {
                                return;
                            }

                            $flattened->push($record);
                            $visited[$recordKey] = true;

                            $traverse($recordKey);
                        });
                }

                return $flattened;
            });

            return $this;
        });

说明:这一步最为关键。'rootValues' => [0, '0', null, -1, '-1'] ,可以指定你的顶级 parent_id 对应的值。

3 加上外部js

resources/js/custom.js

const resolveRecordId = (row) => {
    if (!row) {
        return null;
    }

    const dataId = row.dataset?.recordId ?? row.dataset?.key ?? row.dataset?.id;

    if (dataId) {
        return dataId;
    }

    const wireKey = row.getAttribute('wire:key');

    if (!wireKey) {
        return null;
    }

    const segments = wireKey.split('.');

    return segments.pop() || null;
};

document.addEventListener('click', (event) => {
    const treeCell = event.target.closest('.tree-title');

    if (!treeCell) {
        return;
    }

    const treeRow = treeCell.closest('tr');

    if (!treeRow) {
        return;
    }

    const recordId = resolveRecordId(treeRow);

    if (!recordId) {
        return;
    }

    const childRows = document.querySelectorAll(`.pr-${recordId}`);

    if (!childRows.length) {
        return;
    }

    const isExpanded = Array.from(childRows).every((element) => ! element.classList.contains('hidden'));

    childRows.forEach((element) => {
        element.classList.toggle('hidden', isExpanded);
    });
});

把自定义js注册 在 app/Providers/AppServiceProvider.phpboot 中添加如下代码, 使用 php artisan filament:assets 发布资源

        FilamentAsset::register([
            Js::make('custom-script', __DIR__ . '/../../resources/js/custom.js'),
        ]);

4 在表格中使用

<?php

namespace App\Filament\Resources\AdminMenus\Tables;

use App\Models\AdminMenu;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\HtmlString;
use Livewire\Component;
use Filament\Tables\Enums\FiltersLayout;
class AdminMenusTable
{
    public static function configure(Table $table): Table
    {
        return $table
            ->treeView() // 加上 treeView 宏定义
            ->modifyQueryUsing(fn (Builder $query) => $query->with('parent')->withCount(['children as children_count']))
            ->columns([
                TextColumn::make('id')
                    ->label('ID')
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),

                TextColumn::make('title')
                    ->extraAttributes(['class' => 'tree-title']) // 这个是必须要设置的,否则无法点击展开
                    ->label('标题')
                    ->icon(fn (AdminMenu $record) => $record->children_count ? 'heroicon-o-chevron-right' : null)
                    ->searchable()
                    ->html()
                    ->formatStateUsing(
                        fn (string $state, AdminMenu $record): HtmlString => new HtmlString(
                            str_repeat('&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;', $record->depth()) . e($state)
                        )
                    ),
                ]);
    }
}

存在不完美的地方

  1. 图标不会因展开,收缩而变化。
  2. 当点击[编辑]等,其它行动作时,会自动收缩。

总结

虽然过程有点绕,但是一次搞好后,其它地方数据表格都可以使用。
暂时还没有想到更好的解决办法。本人在此抛砖引玉。
filament v3,是否也可以使用,我想应该也是可以的。但本人精力有限,没有去做测试。

如果您也在用filament ,遇到什么难题,可以进群交流

VCYDGnFEvh.png!large

本作品采用《CC 协议》,转载必须注明作者和本文链接
Dcat-Admin (plus版)是汇聚Filament,Laravel-admin , Dcat-admin 优点于一身的基于Laravel + Bootstrap 的极速开发框架
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 3

现在转 filament 了吗?dcat plus不搞了?

11小时前 评论
Dcatplus-杨光 (楼主) 11小时前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!
Dcat-plus Admin @ 速码邦
文章
40
粉丝
59
喜欢
211
收藏
165
排名:372
访问:2.5 万
私信
所有博文
社区赞助商