record = $this->resolveRecord($record); $this->loadGraph(); } public function getTitle(): string { return $this->getRecord()->name . ' - 页面流程'; } public function loadGraph(): void { $pages = $this->getRecord()->pages()->get(); $edgeModels = $this->getRecord()->edges()->orderBy('sort')->get(); // Build adjacency list $children = []; foreach ($pages as $p) { $children[$p->id] = []; } foreach ($edgeModels as $e) { if (isset($children[$e->from_page_id])) { $children[$e->from_page_id][] = $e->to_page_id; } } // BFS from entry nodes (no incoming edges) to assign levels $hasIncoming = array_flip($edgeModels->pluck('to_page_id')->toArray()); $levels = []; $visited = []; $queue = []; foreach ($pages as $p) { if (!isset($hasIncoming[$p->id])) { $queue[] = $p->id; $levels[$p->id] = 0; $visited[$p->id] = true; } } while (!empty($queue)) { $cur = array_shift($queue); foreach ($children[$cur] ?? [] as $child) { if (!isset($visited[$child])) { $visited[$child] = true; $levels[$child] = $levels[$cur] + 1; $queue[] = $child; } } } // Orphans at bottom $maxLevel = empty($levels) ? 0 : max($levels); foreach ($pages as $p) { if (!isset($levels[$p->id])) { $levels[$p->id] = $maxLevel + 1; } } // Group by level $levelGroups = []; foreach ($pages as $p) { $levelGroups[$levels[$p->id]][] = $p->id; } ksort($levelGroups); // Compute positions: center each level horizontally, stack vertically $nodeWidth = 240; $gapX = 40; $gapY = 150; $positions = []; foreach ($levelGroups as $level => $ids) { $count = count($ids); $totalWidth = $count * $nodeWidth + ($count - 1) * $gapX; $startX = max(20, (800 - $totalWidth) / 2); foreach ($ids as $i => $id) { $positions[$id] = [ 'x' => (int) ($startX + $i * ($nodeWidth + $gapX)), 'y' => 40 + $level * $gapY, ]; } } $this->nodes = $pages->map(fn(GuidePage $p) => [ 'id' => $p->id, 'title' => $p->title, 'uri' => $p->uri, 'is_entry' => !isset($hasIncoming[$p->id]), 'options' => $p->options ?? [], 'x' => $positions[$p->id]['x'] ?? 50, 'y' => $positions[$p->id]['y'] ?? 50, ])->values()->toArray(); $this->edges = $edgeModels->map(fn(GuidePageEdge $e) => [ 'id' => $e->id, 'from' => $e->from_page_id, 'to' => $e->to_page_id, 'label' => $e->label, ])->values()->toArray(); } // -- Livewire methods called by Drawflow events -- public function addEdge(int $fromPageId, int $toPageId, string $outputClass = 'output_1'): void { $guide = $this->getRecord(); if ( !$guide->pages()->where('id', $fromPageId)->exists() || !$guide->pages()->where('id', $toPageId)->exists() ) { return; } if ($fromPageId === $toPageId) { return; } $exists = $guide->edges() ->where('from_page_id', $fromPageId) ->where('to_page_id', $toPageId) ->exists(); if ($exists) { return; } // Derive label from output port → page options mapping $page = $guide->pages()->find($fromPageId); $options = $page->options ?? []; $label = null; if (!empty($options)) { $outputIndex = (int) str_replace('output_', '', $outputClass) - 1; $label = $options[$outputIndex] ?? null; } $guide->edges()->create([ 'from_page_id' => $fromPageId, 'to_page_id' => $toPageId, 'label' => $label, ]); $this->loadGraph(); $this->dispatch('graphUpdated'); } public function removeEdge(int $fromPageId, int $toPageId): void { $this->getRecord()->edges() ->where('from_page_id', $fromPageId) ->where('to_page_id', $toPageId) ->delete(); $this->loadGraph(); $this->dispatch('graphUpdated'); } // -- Filament Actions -- public function createPageAction(): Action { return Action::make('createPage') ->label('添加页面') ->icon('heroicon-o-plus') ->form($this->getPageFormSchema()) ->action(function (array $data): void { $this->getRecord()->pages()->create($data); $this->loadGraph(); $this->dispatch('graphUpdated'); }); } public function editPageAction(): Action { return Action::make('editPage') ->label('编辑页面') ->icon('heroicon-o-pencil-square') ->mountUsing(function (Forms\Form $form, array $arguments): void { $page = $this->getRecord()->pages()->findOrFail($arguments['id']); $form->fill([ 'title' => $page->title, 'content' => $page->content, 'options' => $page->options ?? [], ]); }) ->form($this->getPageFormSchema()) ->action(function (array $data, array $arguments): void { $page = $this->getRecord()->pages()->findOrFail($arguments['id']); $page->update($data); $this->loadGraph(); $this->dispatch('graphUpdated'); }); } public function deletePageAction(): Action { return Action::make('deletePage') ->label('删除页面') ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() ->action(function (array $arguments): void { $page = $this->getRecord()->pages()->findOrFail($arguments['id']); $page->delete(); $this->loadGraph(); $this->dispatch('graphUpdated'); }); } public function deleteEdgeAction(): Action { return Action::make('deleteEdge') ->label('删除连线') ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() ->action(function (array $arguments): void { $edge = $this->getRecord()->edges()->findOrFail($arguments['id']); $edge->delete(); $this->loadGraph(); $this->dispatch('graphUpdated'); }); } private function getPageFormSchema(): array { return [ Forms\Components\TextInput::make('title') ->label('页面标题') ->required() ->maxLength(255), Forms\Components\RichEditor::make('content') ->label('页面内容') ->required() ->fileAttachmentsDisk('public') ->fileAttachmentsDirectory('guide-pages') ->fileAttachmentsVisibility('public') ->columnSpanFull(), Forms\Components\TagsInput::make('options') ->label('分支选项') ->helperText('定义此页面的分支按钮(每个选项对应一个输出端口)。留空 = 顺序页面。'), ]; } }