fix: tree & guide

This commit is contained in:
2026-03-24 09:21:21 +08:00
parent b74ba1a3f8
commit 42a879e961
15 changed files with 2619 additions and 601 deletions

View File

@@ -305,7 +305,7 @@ class DocumentResource extends Resource
public static function formatFileSize(?int $bytes): string
{
if ($bytes === null) {
return '';
return '-';
}
$units = ['B', 'KB', 'MB', 'GB'];

View File

@@ -3,47 +3,259 @@
namespace App\Filament\Resources\GuideResource\Pages;
use App\Filament\Resources\GuideResource;
use App\Models\Guide;
use App\Models\GuidePage;
use App\Models\GuidePageEdge;
use Filament\Actions\Action;
use Filament\Forms;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use SolutionForest\FilamentTree\Actions;
use SolutionForest\FilamentTree\Resources\Pages\TreePage;
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use Filament\Resources\Pages\Page;
class ManageGuidePages extends TreePage
class ManageGuidePages extends Page
{
use InteractsWithRecord;
protected static string $resource = GuideResource::class;
protected ?string $treeTitle = '指引页面';
protected bool $enableTreeTitle = true;
protected static string $view = 'filament.resources.guide.manage-pages';
protected static string $model = GuidePage::class;
public array $nodes = [];
protected function getTreeQuery(): Builder
public array $edges = [];
public function mount(int|string $record): void
{
return GuidePage::query()
->where('guide_id', $this->getOwnerRecord()->id);
$this->record = $this->resolveRecord($record);
$this->loadGraph();
}
public function getTreeRecordTitle(?Model $record = null): string
public function getTitle(): string
{
if (!$record) {
return '';
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] = [];
}
$prefix = $record->branch_option ? "[{$record->branch_option}] " : '';
$suffix = !empty($record->options) ? ' 📋' : '';
return $prefix . $record->title . $suffix;
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,
'html_url' => $p->html_url,
'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();
}
protected function getFormSchema(): array
// -- 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,
'html_url' => $page->html_url,
'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('page_number')
->label('页码')
->numeric()
->minValue(1),
Forms\Components\TextInput::make('title')
->label('页面标题')
->required()
@@ -56,33 +268,8 @@ class ManageGuidePages extends TreePage
->maxLength(500),
Forms\Components\TagsInput::make('options')
->label('选项按钮')
->helperText('此页面展示的选项按钮,如"前门12"、"后门"。留空=无分支。'),
Forms\Components\TextInput::make('branch_option')
->label('所属分支选项')
->maxLength(100)
->helperText('此页面对应父页面的哪个选项值(根页面留空)'),
->label('分支选项')
->helperText('定义此页面的分支按钮(每个选项对应一个输出端口)。留空 = 顺序页面。'),
];
}
protected function getTreeActions(): array
{
return [
Actions\ViewAction::make(),
Actions\EditAction::make()->slideOver(),
Actions\DeleteAction::make(),
];
}
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['guide_id'] = $this->getOwnerRecord()->id;
return $data;
}
protected function getOwnerRecord(): Guide
{
return Guide::findOrFail(request()->route('record'));
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Guide;
use App\Models\GuidePage;
use App\Models\GuidePageEdge;
use App\Services\KnowledgeContextService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -90,7 +91,7 @@ class TerminalApiController extends Controller
/**
* POST /api/terminal/guides/pages
* 组合多个指引页面,返回递归树形结构
* 返回指引页面(状态机格式,每页带 next 指针)
*/
public function guidePages(Request $request): JsonResponse
{
@@ -102,104 +103,54 @@ class TerminalApiController extends Controller
$terminal = $request->attributes->get('terminal');
$accessibleIds = $this->getTerminalGuides($terminal)->pluck('guides.id')->toArray();
$guideIds = $request->input('guide_ids');
$pages = [];
$guideIds = collect($request->input('guide_ids'))
->intersect($accessibleIds)
->values()
->toArray();
foreach ($guideIds as $guideId) {
if (!in_array($guideId, $accessibleIds)) {
continue;
$pages = GuidePage::whereIn('guide_id', $guideIds)->get();
$edges = GuidePageEdge::whereIn('guide_id', $guideIds)
->orderBy('from_page_id')
->orderBy('sort')
->get();
$edgesByFrom = $edges->groupBy('from_page_id');
$hasIncoming = $edges->pluck('to_page_id')->unique()->flip();
$guides = [];
foreach ($pages->groupBy('guide_id') as $guideId => $guidePages) {
$entryPage = $guidePages->first(fn($p) => !$hasIncoming->has($p->id));
$pagesMap = [];
foreach ($guidePages as $page) {
$next = $edgesByFrom->get($page->id, collect())
->map(function (GuidePageEdge $e) {
$item = ['page_id' => $e->to_page_id];
if ($e->label !== null) {
$item['label'] = $e->label;
}
return $item;
})->values()->toArray();
$pagesMap[$page->id] = [
'id' => $page->id,
'title' => $page->title,
'html_url' => $page->html_url,
'next' => $next,
];
}
$guide = Guide::with(
$this->buildEagerLoadArray('trunkPages', 5)
)->find($guideId);
if (!$guide) {
continue;
}
foreach ($guide->trunkPages as $page) {
$pages = array_merge($pages, $this->flattenSequentialPages($page, $guide->name, $guide->id));
}
$guides[$guideId] = [
'entry_page_id' => $entryPage?->id,
'pages' => $pagesMap,
];
}
return response()->json([
'pages' => $pages,
'total_pages' => count($pages),
'guides' => $guides,
]);
}
/**
* 将树形页面结构展平:顺序节点(无 options平铺分支节点保留嵌套
*/
private function flattenSequentialPages(GuidePage $page, string $guideName, int $guideId): array
{
$data = [
'id' => $page->id,
'guide_id' => $guideId,
'guide_name' => $guideName,
'page_number' => $page->page_number,
'title' => $page->title,
'html_url' => $page->html_url,
];
if ($page->options && $page->branchChildren->isNotEmpty()) {
$data['options'] = $page->options;
$branches = [];
foreach ($page->branchChildren as $child) {
$branches[$child->branch_option][] =
$this->buildPageTree($child, $guideName, $guideId);
}
$data['branches'] = $branches;
return [$data];
}
if ($page->branchChildren->isNotEmpty()) {
$result = [$data];
foreach ($page->branchChildren as $child) {
$result = array_merge($result, $this->flattenSequentialPages($child, $guideName, $guideId));
}
return $result;
}
return [$data];
}
private function buildPageTree(GuidePage $page, string $guideName, int $guideId): array
{
$data = [
'id' => $page->id,
'guide_id' => $guideId,
'guide_name' => $guideName,
'page_number' => $page->page_number,
'title' => $page->title,
'html_url' => $page->html_url,
];
if ($page->options && $page->branchChildren->isNotEmpty()) {
$data['options'] = $page->options;
$branches = [];
foreach ($page->branchChildren as $child) {
$branches[$child->branch_option][] =
$this->buildPageTree($child, $guideName, $guideId);
}
$data['branches'] = $branches;
}
return $data;
}
private function buildEagerLoadArray(string $base, int $depth): array
{
$loads = [$base => fn($q) => $q->orderBy('sort_order')];
$current = $base;
for ($i = 0; $i < $depth; $i++) {
$current .= '.branchChildren';
$loads[$current] = fn($q) => $q->orderBy('sort_order');
}
return $loads;
}
/**
* 获取终端可见的指引(线站关联 + 全局)
*/
@@ -210,7 +161,7 @@ class TerminalApiController extends Controller
return Guide::published()->where(function ($q) use ($stationId) {
$q->whereDoesntHave('stations'); // 全局指引
if ($stationId) {
$q->orWhereHas('stations', fn ($sq) => $sq->where('stations.id', $stationId));
$q->orWhereHas('stations', fn($sq) => $sq->where('stations.id', $stationId));
}
});
}

View File

@@ -33,14 +33,22 @@ class Guide extends Model
public function pages()
{
return $this->hasMany(GuidePage::class)->orderBy('sort_order');
return $this->hasMany(GuidePage::class);
}
public function trunkPages()
public function edges()
{
return $this->hasMany(GuidePage::class)
->where('parent_id', -1)
->orderBy('sort_order');
return $this->hasMany(GuidePageEdge::class);
}
public function entryPage()
{
return $this->hasOne(GuidePage::class)
->whereNotIn('guide_pages.id', function ($q) {
$q->select('to_page_id')
->from('guide_page_edges')
->whereColumn('guide_page_edges.guide_id', 'guide_pages.guide_id');
});
}
public function creator()
@@ -76,7 +84,7 @@ class Guide extends Model
return $query->where(function (Builder $q) use ($stationIds) {
$q->whereDoesntHave('stations')
->orWhereHas('stations', fn ($sq) => $sq->whereIn('stations.id', $stationIds));
->orWhereHas('stations', fn($sq) => $sq->whereIn('stations.id', $stationIds));
});
}

View File

@@ -3,42 +3,26 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use SolutionForest\FilamentTree\Concern\ModelTree;
class GuidePage extends Model
{
use ModelTree;
protected $fillable = [
'guide_id',
'page_number',
'title',
'html_url',
'sort_order',
'parent_id',
'options',
'branch_option',
];
protected $casts = [
'options' => 'array',
'parent_id' => 'int',
];
// filament-tree column name mapping
public function determineParentColumnName(): string
protected static function booted(): void
{
return 'parent_id';
}
public function determineOrderColumnName(): string
{
return 'sort_order';
}
public function determineTitleColumnName(): string
{
return 'title';
static::deleting(function (GuidePage $page) {
// CASCADE on from_page_id is handled by FK, but incoming edges need cleanup
GuidePageEdge::where('to_page_id', $page->id)->delete();
});
}
public function guide()
@@ -46,13 +30,32 @@ class GuidePage extends Model
return $this->belongsTo(Guide::class);
}
public function branchChildren()
public function outgoingEdges()
{
return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order');
return $this->hasMany(GuidePageEdge::class, 'from_page_id')->orderBy('sort');
}
public function parentPage()
public function incomingEdges()
{
return $this->belongsTo(self::class, 'parent_id');
return $this->hasMany(GuidePageEdge::class, 'to_page_id');
}
public function nextPages()
{
return $this->belongsToMany(self::class, 'guide_page_edges', 'from_page_id', 'to_page_id')
->withPivot('label', 'sort')
->orderByPivot('sort');
}
public function previousPages()
{
return $this->belongsToMany(self::class, 'guide_page_edges', 'to_page_id', 'from_page_id')
->withPivot('label', 'sort');
}
public function isEntry(): bool
{
return !$this->incomingEdges()->exists();
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GuidePageEdge extends Model
{
protected $fillable = [
'guide_id',
'from_page_id',
'to_page_id',
'label',
'sort',
];
public function guide()
{
return $this->belongsTo(Guide::class);
}
public function fromPage()
{
return $this->belongsTo(GuidePage::class, 'from_page_id');
}
public function toPage()
{
return $this->belongsTo(GuidePage::class, 'to_page_id');
}
}