392 lines
13 KiB
PHP
392 lines
13 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';
|
|
|
|
protected ?string $maxContentWidth = 'full';
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
$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('定义此页面的分支按钮(每个选项对应一个输出端口)。留空 = 顺序页面。'),
|
|
];
|
|
}
|
|
}
|