Files
KnowledgeBase/app/Filament/Resources/GuideResource/Pages/ManageGuidePages.php
2026-04-16 16:20:52 +08:00

278 lines
8.4 KiB
PHP

<?php
namespace App\Filament\Resources\GuideResource\Pages;
use App\Filament\Resources\GuideResource;
use App\Models\GuidePage;
use App\Models\GuidePageEdge;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use Filament\Resources\Pages\Page;
class ManageGuidePages extends Page
{
use InteractsWithRecord;
protected static string $resource = GuideResource::class;
protected static string $view = 'filament.resources.guide.manage-pages';
public array $nodes = [];
public array $edges = [];
public function mount(int|string $record): void
{
$this->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('定义此页面的分支按钮(每个选项对应一个输出端口)。留空 = 顺序页面。'),
];
}
}