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; } } $pageMap = $pages->keyBy('id'); $incomingEdges = []; $outgoingEdges = []; foreach ($pages as $p) { $incomingEdges[$p->id] = []; $outgoingEdges[$p->id] = []; } foreach ($edgeModels as $e) { $incomingEdges[$e->to_page_id][] = $e; $outgoingEdges[$e->from_page_id][] = $e; } // 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); $orders = []; foreach ($levelGroups as $ids) { foreach (array_values($ids) as $index => $id) { $orders[$id] = $index; } } $edgeOffset = function (GuidePageEdge $edge) use ($pageMap): float { $page = $pageMap->get($edge->from_page_id); $options = $page?->options ?? []; $index = $edge->label === null ? false : array_search($edge->label, $options, true); return $index === false ? 0 : (($index + 1) / (count($options) + 1)) * 0.4; }; $incomingScore = function (int $id) use (&$orders, $incomingEdges, $levels, $edgeOffset): ?float { $scores = []; foreach ($incomingEdges[$id] ?? [] as $edge) { if (($levels[$edge->from_page_id] ?? null) >= ($levels[$id] ?? null)) { continue; } $scores[] = ($orders[$edge->from_page_id] ?? 0) + $edgeOffset($edge); } return empty($scores) ? null : array_sum($scores) / count($scores); }; $outgoingScore = function (int $id) use (&$orders, $outgoingEdges, $levels): ?float { $scores = []; foreach ($outgoingEdges[$id] ?? [] as $edge) { if (($levels[$edge->to_page_id] ?? null) <= ($levels[$id] ?? null)) { continue; } $scores[] = $orders[$edge->to_page_id] ?? 0; } return empty($scores) ? null : array_sum($scores) / count($scores); }; $sortLevel = function (array &$ids, callable $scoreResolver) use (&$orders): void { usort($ids, function (int $a, int $b) use ($scoreResolver, $orders): int { $scoreA = $scoreResolver($a) ?? ($orders[$a] ?? 0); $scoreB = $scoreResolver($b) ?? ($orders[$b] ?? 0); return $scoreA <=> $scoreB ?: ($orders[$a] ?? 0) <=> ($orders[$b] ?? 0) ?: $a <=> $b; }); foreach ($ids as $index => $id) { $orders[$id] = $index; } }; for ($i = 0; $i < 3; $i++) { foreach ($levelGroups as $level => &$ids) { if ($level === array_key_first($levelGroups)) { continue; } $sortLevel($ids, $incomingScore); } unset($ids); foreach (array_reverse(array_keys($levelGroups)) as $level) { if ($level === array_key_last($levelGroups)) { continue; } $sortLevel($levelGroups[$level], $outgoingScore); } } // Compute positions: center each level vertically, stack horizontally (left-to-right) $nodeWidth = 180; // matches CSS max-width $nodeHeight = 80; // compact node height $gapX = 110; // horizontal gap between levels $gapY = 60; // vertical gap within same level $positions = []; foreach ($levelGroups as $level => $ids) { $count = count($ids); $totalHeight = $count * $nodeHeight + ($count - 1) * $gapY; $startY = max(20, (600 - $totalHeight) / 2); foreach ($ids as $i => $id) { $positions[$id] = [ 'x' => 40 + $level * ($nodeWidth + $gapX), 'y' => (int) ($startY + $i * ($nodeHeight + $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 -- private function dispatchGraphUpdated(): void { $this->dispatch('graphUpdated', nodes: $this->nodes, edges: $this->edges); } 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->dispatchGraphUpdated(); } 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->dispatchGraphUpdated(); } // -- 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->dispatchGraphUpdated(); }); } 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->normalized_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->dispatchGraphUpdated(); }); } public function copyPageAction(): Action { return Action::make('copyPage') ->label('复制页面') ->icon('heroicon-o-document-duplicate') ->requiresConfirmation() ->modalHeading('复制页面') ->modalDescription('确认复制该页面?复制后会生成一个独立的新页面,不会复制连线关系。') ->modalSubmitActionLabel('确认复制') ->action(function (array $arguments): void { $page = $this->getRecord()->pages()->findOrFail($arguments['id']); $this->getRecord()->pages()->create([ 'title' => $page->title.' - 副本', 'content' => $page->content, 'options' => $page->options ?? [], ]); $this->loadGraph(); $this->dispatchGraphUpdated(); }); } 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->dispatchGraphUpdated(); }); } 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->dispatchGraphUpdated(); }); } 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') ->getUploadedAttachmentUrlUsing(fn (string $file): string => GuidePage::uploadedAttachmentUrl($file)) ->dehydrateStateUsing(fn (?string $state): string => GuidePage::normalizeRichTextContent($state)) ->columnSpanFull(), Forms\Components\TagsInput::make('options') ->label('分支选项') ->helperText('定义此页面的分支按钮(每个选项对应一个输出端口)。留空 = 顺序页面。'), ]; } }