fix: tree & guide
This commit is contained in:
@@ -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'];
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
31
app/Models/GuidePageEdge.php
Normal file
31
app/Models/GuidePageEdge.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@
|
||||
"maatwebsite/excel": "^3.1",
|
||||
"meilisearch/meilisearch-php": "^1.16",
|
||||
"paperdoc-dev/paperdoc-lib": "^0.3.5",
|
||||
"solution-forest/filament-tree": "^2.0",
|
||||
"spatie/laravel-activitylog": "^4.12",
|
||||
"spatie/laravel-permission": "^6.24"
|
||||
},
|
||||
|
||||
72
composer.lock
generated
72
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "7ac0bdce6d17a09797646ea53c4b658a",
|
||||
"content-hash": "fc9b62a7a28737298d8a172dd410dab9",
|
||||
"packages": [
|
||||
{
|
||||
"name": "abdelhamiderrahmouni/filament-monaco-editor",
|
||||
@@ -6143,76 +6143,6 @@
|
||||
],
|
||||
"time": "2026-03-19T10:36:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "solution-forest/filament-tree",
|
||||
"version": "2.1.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/solutionforest/filament-tree.git",
|
||||
"reference": "de8b27c7c58f1e8c8e1a3081dff2e477b4327301"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/solutionforest/filament-tree/zipball/de8b27c7c58f1e8c8e1a3081dff2e477b4327301",
|
||||
"reference": "de8b27c7c58f1e8c8e1a3081dff2e477b4327301",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"filament/filament": "^3.0",
|
||||
"filament/support": "^3.0",
|
||||
"php": "^8.1",
|
||||
"spatie/laravel-package-tools": "^1.15.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.0",
|
||||
"nunomaduro/collision": "^7.9",
|
||||
"nunomaduro/larastan": "^2.0.1",
|
||||
"orchestra/testbench": "^8.0",
|
||||
"pestphp/pest": "^2.0",
|
||||
"pestphp/pest-plugin-arch": "^2.0",
|
||||
"pestphp/pest-plugin-laravel": "^2.0",
|
||||
"phpstan/extension-installer": "^1.1",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0",
|
||||
"spatie/laravel-ray": "^1.26"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"SolutionForest\\FilamentTree\\FilamentTreeServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"SolutionForest\\FilamentTree\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Carly",
|
||||
"email": "info@solutionforest.net",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "This is a tree layout plugin for Filament Admin",
|
||||
"homepage": "https://github.com/solution-forest/filament-tree",
|
||||
"keywords": [
|
||||
"Solution Forest",
|
||||
"filament-tree",
|
||||
"laravel"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/solution-forest/filament-tree/issues",
|
||||
"source": "https://github.com/solution-forest/filament-tree"
|
||||
},
|
||||
"time": "2025-08-11T09:35:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/color",
|
||||
"version": "1.8.0",
|
||||
|
||||
@@ -27,17 +27,26 @@ return new class extends Migration
|
||||
Schema::create('guide_pages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('guide_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedInteger('page_number')->comment('页码');
|
||||
$table->string('title')->comment('页面标题');
|
||||
$table->string('html_url', 500)->comment('HTML页面链接');
|
||||
$table->integer('parent_id')->default(-1);
|
||||
$table->unsignedInteger('sort_order')->default(0)->comment('排序');
|
||||
$table->json('options')->nullable();
|
||||
$table->string('branch_option', 100)->nullable();
|
||||
$table->json('options')->nullable()->comment('分支选项(定义输出端口)');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('parent_id');
|
||||
$table->index(['guide_id', 'sort_order']);
|
||||
$table->index('guide_id');
|
||||
});
|
||||
|
||||
Schema::create('guide_page_edges', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('guide_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('from_page_id')->constrained('guide_pages')->cascadeOnDelete();
|
||||
$table->foreignId('to_page_id')->constrained('guide_pages')->cascadeOnDelete();
|
||||
$table->string('label', 100)->nullable()->comment('选项按钮文字, null=顺序连接');
|
||||
$table->unsignedInteger('sort')->default(0)->comment('选项排序');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['from_page_id', 'label']);
|
||||
$table->index('guide_id');
|
||||
$table->index('to_page_id');
|
||||
});
|
||||
|
||||
Schema::create('guide_station', function (Blueprint $table) {
|
||||
@@ -53,6 +62,7 @@ return new class extends Migration
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('guide_station');
|
||||
Schema::dropIfExists('guide_page_edges');
|
||||
Schema::dropIfExists('guide_pages');
|
||||
Schema::dropIfExists('guides');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Database\Seeders;
|
||||
|
||||
use App\Models\Guide;
|
||||
use App\Models\GuidePage;
|
||||
use App\Models\GuidePageEdge;
|
||||
use App\Models\Station;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
@@ -41,6 +42,7 @@ class GuideSeeder extends Seeder
|
||||
$this->command->info('操作指引数据创建完成!');
|
||||
$this->command->info(' - 指引数量: ' . Guide::count());
|
||||
$this->command->info(' - 指引页面数量: ' . GuidePage::count());
|
||||
$this->command->info(' - 指引边数量: ' . GuidePageEdge::count());
|
||||
$this->command->info(' - 关联线站数量: ' . $stations->count());
|
||||
}
|
||||
|
||||
@@ -60,77 +62,110 @@ class GuideSeeder extends Seeder
|
||||
|
||||
$baseUrl = self::BASE_URL . '/how-to-use-beam';
|
||||
|
||||
// 步骤1: 打开光子光闸 PS1(根节点)
|
||||
$step1 = GuidePage::create([
|
||||
'guide_id' => $guide->id,
|
||||
'page_number' => 1,
|
||||
'title' => '打开光子光闸 PS1',
|
||||
'html_url' => "{$baseUrl}/step-1.html",
|
||||
'parent_id' => -1,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
// 步骤2: 搜索光学棚屋(带选项:前门12 / 后门)
|
||||
$step2 = GuidePage::create([
|
||||
'guide_id' => $guide->id,
|
||||
'page_number' => 2,
|
||||
'title' => '搜索光学棚屋',
|
||||
'html_url' => "{$baseUrl}/step-2.html",
|
||||
'parent_id' => $step1->id,
|
||||
'sort_order' => 1,
|
||||
'options' => ['前门12', '后门'],
|
||||
]);
|
||||
|
||||
// 步骤3a: 前门12路径 - 检查设备状态
|
||||
$step3a = GuidePage::create([
|
||||
'guide_id' => $guide->id,
|
||||
'page_number' => 3,
|
||||
'title' => '前门12路径 - 检查设备状态',
|
||||
'html_url' => "{$baseUrl}/step-3a.html",
|
||||
'parent_id' => $step2->id,
|
||||
'sort_order' => 0,
|
||||
'branch_option' => '前门12',
|
||||
]);
|
||||
|
||||
// 步骤3b: 后门路径 - 安全确认
|
||||
$step3b = GuidePage::create([
|
||||
'guide_id' => $guide->id,
|
||||
'page_number' => 3,
|
||||
'title' => '后门路径 - 安全确认',
|
||||
'html_url' => "{$baseUrl}/step-3b.html",
|
||||
'parent_id' => $step2->id,
|
||||
'sort_order' => 1,
|
||||
'branch_option' => '后门',
|
||||
]);
|
||||
|
||||
// 步骤4a: 前门12路径 - 打开实验站光闸
|
||||
GuidePage::create([
|
||||
$step4a = GuidePage::create([
|
||||
'guide_id' => $guide->id,
|
||||
'page_number' => 4,
|
||||
'title' => '前门12路径 - 打开实验站光闸',
|
||||
'html_url' => "{$baseUrl}/step-4a.html",
|
||||
'parent_id' => $step3a->id,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
// 步骤4b: 后门路径 - 设备检查
|
||||
GuidePage::create([
|
||||
$step4b = GuidePage::create([
|
||||
'guide_id' => $guide->id,
|
||||
'page_number' => 4,
|
||||
'title' => '后门路径 - 设备检查',
|
||||
'html_url' => "{$baseUrl}/step-4b.html",
|
||||
'parent_id' => $step3b->id,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
// 步骤5: 完成(根节点,最终汇合)
|
||||
GuidePage::create([
|
||||
$step5 = GuidePage::create([
|
||||
'guide_id' => $guide->id,
|
||||
'page_number' => 5,
|
||||
'title' => '完成',
|
||||
'html_url' => "{$baseUrl}/step-5.html",
|
||||
'parent_id' => -1,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
// step1 → step2 (sequential)
|
||||
GuidePageEdge::create([
|
||||
'guide_id' => $guide->id,
|
||||
'from_page_id' => $step1->id,
|
||||
'to_page_id' => $step2->id,
|
||||
'label' => null,
|
||||
'sort' => 0,
|
||||
]);
|
||||
|
||||
// step2 → step3a (branching option: 前门12)
|
||||
GuidePageEdge::create([
|
||||
'guide_id' => $guide->id,
|
||||
'from_page_id' => $step2->id,
|
||||
'to_page_id' => $step3a->id,
|
||||
'label' => '前门12',
|
||||
'sort' => 0,
|
||||
]);
|
||||
|
||||
// step2 → step3b (branching option: 后门)
|
||||
GuidePageEdge::create([
|
||||
'guide_id' => $guide->id,
|
||||
'from_page_id' => $step2->id,
|
||||
'to_page_id' => $step3b->id,
|
||||
'label' => '后门',
|
||||
'sort' => 1,
|
||||
]);
|
||||
|
||||
// step3a → step4a (sequential)
|
||||
GuidePageEdge::create([
|
||||
'guide_id' => $guide->id,
|
||||
'from_page_id' => $step3a->id,
|
||||
'to_page_id' => $step4a->id,
|
||||
'label' => null,
|
||||
'sort' => 0,
|
||||
]);
|
||||
|
||||
// step3b → step4b (sequential)
|
||||
GuidePageEdge::create([
|
||||
'guide_id' => $guide->id,
|
||||
'from_page_id' => $step3b->id,
|
||||
'to_page_id' => $step4b->id,
|
||||
'label' => null,
|
||||
'sort' => 0,
|
||||
]);
|
||||
|
||||
// step4a → step5 (convergence)
|
||||
GuidePageEdge::create([
|
||||
'guide_id' => $guide->id,
|
||||
'from_page_id' => $step4a->id,
|
||||
'to_page_id' => $step5->id,
|
||||
'label' => null,
|
||||
'sort' => 0,
|
||||
]);
|
||||
|
||||
// step4b → step5 (convergence)
|
||||
GuidePageEdge::create([
|
||||
'guide_id' => $guide->id,
|
||||
'from_page_id' => $step4b->id,
|
||||
'to_page_id' => $step5->id,
|
||||
'label' => null,
|
||||
'sort' => 0,
|
||||
]);
|
||||
|
||||
return $guide;
|
||||
@@ -159,17 +194,24 @@ class GuideSeeder extends Seeder
|
||||
['title' => '联系维护人员', 'file' => 'step-5.html'],
|
||||
];
|
||||
|
||||
$parentId = -1;
|
||||
$pages = [];
|
||||
foreach ($steps as $i => $step) {
|
||||
$page = GuidePage::create([
|
||||
$pages[] = GuidePage::create([
|
||||
'guide_id' => $guide->id,
|
||||
'page_number' => $i + 1,
|
||||
'title' => $step['title'],
|
||||
'html_url' => "{$baseUrl}/{$step['file']}",
|
||||
'parent_id' => $parentId,
|
||||
'sort_order' => $parentId === -1 ? $i : 0,
|
||||
]);
|
||||
$parentId = $page->id;
|
||||
}
|
||||
|
||||
// Sequential edges: A→B→C→D→E
|
||||
for ($i = 0; $i < count($pages) - 1; $i++) {
|
||||
GuidePageEdge::create([
|
||||
'guide_id' => $guide->id,
|
||||
'from_page_id' => $pages[$i]->id,
|
||||
'to_page_id' => $pages[$i + 1]->id,
|
||||
'label' => null,
|
||||
'sort' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
return $guide;
|
||||
@@ -198,17 +240,24 @@ class GuideSeeder extends Seeder
|
||||
['title' => '完成', 'file' => 'step-5.html'],
|
||||
];
|
||||
|
||||
$parentId = -1;
|
||||
$pages = [];
|
||||
foreach ($steps as $i => $step) {
|
||||
$page = GuidePage::create([
|
||||
$pages[] = GuidePage::create([
|
||||
'guide_id' => $guide->id,
|
||||
'page_number' => $i + 1,
|
||||
'title' => $step['title'],
|
||||
'html_url' => "{$baseUrl}/{$step['file']}",
|
||||
'parent_id' => $parentId,
|
||||
'sort_order' => $parentId === -1 ? $i : 0,
|
||||
]);
|
||||
$parentId = $page->id;
|
||||
}
|
||||
|
||||
// Sequential edges: A→B→C→D→E
|
||||
for ($i = 0; $i < count($pages) - 1; $i++) {
|
||||
GuidePageEdge::create([
|
||||
'guide_id' => $guide->id,
|
||||
'from_page_id' => $pages[$i]->id,
|
||||
'to_page_id' => $pages[$i + 1]->id,
|
||||
'label' => null,
|
||||
'sort' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
return $guide;
|
||||
|
||||
1
public/vendor/drawflow/drawflow.min.css
vendored
Normal file
1
public/vendor/drawflow/drawflow.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.drawflow,.drawflow .parent-node{position:relative}.parent-drawflow{display:flex;overflow:hidden;touch-action:none;outline:0}.drawflow{width:100%;height:100%;user-select:none;perspective:0}.drawflow .drawflow-node{display:flex;align-items:center;position:absolute;background:#0ff;width:160px;min-height:40px;border-radius:4px;border:2px solid #000;color:#000;z-index:2;padding:15px}.drawflow .drawflow-node.selected{background:red}.drawflow .drawflow-node:hover{cursor:move}.drawflow .drawflow-node .inputs,.drawflow .drawflow-node .outputs{width:0}.drawflow .drawflow-node .drawflow_content_node{width:100%;display:block}.drawflow .drawflow-node .input,.drawflow .drawflow-node .output{position:relative;width:20px;height:20px;background:#fff;border-radius:50%;border:2px solid #000;cursor:crosshair;z-index:1;margin-bottom:5px}.drawflow .drawflow-node .input{left:-27px;top:2px;background:#ff0}.drawflow .drawflow-node .output{right:-3px;top:2px}.drawflow svg{z-index:0;position:absolute;overflow:visible!important}.drawflow .connection{position:absolute;pointer-events:none;aspect-ratio:1/1}.drawflow .connection .main-path{fill:none;stroke-width:5px;stroke:#4682b4;pointer-events:all}.drawflow .connection .main-path:hover{stroke:#1266ab;cursor:pointer}.drawflow .connection .main-path.selected{stroke:#43b993}.drawflow .connection .point{cursor:move;stroke:#000;stroke-width:2;fill:#fff;pointer-events:all}.drawflow .connection .point.selected,.drawflow .connection .point:hover{fill:#1266ab}.drawflow .main-path{fill:none;stroke-width:5px;stroke:#4682b4}.drawflow-delete{position:absolute;display:block;width:30px;height:30px;background:#000;color:#fff;z-index:4;border:2px solid #fff;line-height:30px;font-weight:700;text-align:center;border-radius:50%;font-family:monospace;cursor:pointer}.drawflow>.drawflow-delete{margin-left:-15px;margin-top:15px}.parent-node .drawflow-delete{right:-15px;top:-15px}
|
||||
1
public/vendor/drawflow/drawflow.min.js
vendored
Normal file
1
public/vendor/drawflow/drawflow.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,26 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>{{ $document->title }} - Markdown 预览</title>
|
||||
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
@vite(['resources/css/app.css'])
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
|
||||
.preview-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
|
||||
.preview-header {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
@@ -28,7 +29,7 @@
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
|
||||
.preview-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
@@ -36,45 +37,46 @@
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
@media print {
|
||||
.preview-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.preview-content {
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.preview-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
||||
.preview-header {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
|
||||
.preview-content {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="preview-container">
|
||||
<!-- 头部信息 -->
|
||||
@@ -82,7 +84,7 @@
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">{{ $document->title }}</h1>
|
||||
|
||||
|
||||
<div class="flex flex-wrap gap-4 text-sm text-gray-600">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -90,7 +92,7 @@
|
||||
</svg>
|
||||
<span>{{ $document->type === 'global' ? '全局知识库' : '专用知识库' }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@if($document->group)
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -99,14 +101,14 @@
|
||||
<span>{{ $document->group->name }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
<span>{{ $document->uploader->name }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
@@ -114,23 +116,23 @@
|
||||
<span>{{ $document->created_at->format('Y年m月d日 H:i') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@if($document->description)
|
||||
<p class="mt-3 text-gray-700">{{ $document->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('documents.download', $document) }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
||||
<a href="{{ route('documents.download', $document) }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
<span>下载原文档</span>
|
||||
</a>
|
||||
|
||||
<button onclick="window.print()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
|
||||
|
||||
<button onclick="window.print()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"></path>
|
||||
</svg>
|
||||
@@ -139,26 +141,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Markdown 内容 -->
|
||||
<div class="preview-content">
|
||||
@if($markdownHtml)
|
||||
{!! $markdownHtml !!}
|
||||
{!! $markdownHtml !!}
|
||||
@else
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📄</div>
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-2">Markdown 内容为空</h2>
|
||||
<p class="text-gray-600 mb-6">该文档的 Markdown 内容尚未生成或为空</p>
|
||||
<a href="{{ route('documents.download', $document) }}"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
<span>下载原始文档</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📄</div>
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-2">Markdown 内容为空</h2>
|
||||
<p class="text-gray-600 mb-6">该文档的 Markdown 内容尚未生成或为空</p>
|
||||
<a href="{{ route('documents.download', $document) }}"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
<span>下载原始文档</span>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
352
resources/views/filament/resources/guide/manage-pages.blade.php
Normal file
352
resources/views/filament/resources/guide/manage-pages.blade.php
Normal file
@@ -0,0 +1,352 @@
|
||||
<x-filament-panels::page>
|
||||
<link rel="stylesheet" href="{{ asset('vendor/drawflow/drawflow.min.css') }}">
|
||||
<script src="{{ asset('vendor/drawflow/drawflow.min.js') }}"></script>
|
||||
|
||||
<div class="space-y-4">
|
||||
{{-- Header actions --}}
|
||||
<div class="flex gap-2">
|
||||
{{ $this->createPageAction }}
|
||||
</div>
|
||||
|
||||
{{-- Drawflow canvas --}}
|
||||
<div class="fi-section rounded-xl bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10 overflow-hidden">
|
||||
<div id="drawflow" wire:ignore></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-filament-actions::modals />
|
||||
|
||||
<style>
|
||||
#drawflow {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
background-color: #f8fafc;
|
||||
background-image: radial-gradient(circle, #e2e8f0 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
/* Node styling */
|
||||
.drawflow .drawflow-node {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
min-width: 200px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.drawflow .drawflow-node.selected {
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.drawflow .drawflow-node.entry-node {
|
||||
border: 2px solid #f59e0b;
|
||||
}
|
||||
|
||||
.drawflow .drawflow-node .drawflow_content_node {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Node content */
|
||||
.df-node-content {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.df-node-header {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.df-node-url {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.df-node-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.df-node-actions {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.df-node-actions button {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.df-node-actions button:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.df-node-actions button.btn-danger:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Connection styling */
|
||||
.drawflow .connection .main-path {
|
||||
stroke: #94a3b8;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.drawflow .connection .main-path:hover {
|
||||
stroke: #ef4444;
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
/* Port containers: top (input) / bottom (output) */
|
||||
.drawflow .drawflow-node .inputs {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: auto !important;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
.drawflow .drawflow-node .outputs {
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: auto !important;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-evenly;
|
||||
gap: 24px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Reset Drawflow default offsets on individual ports */
|
||||
.drawflow .drawflow-node .input {
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
margin: 0;
|
||||
}
|
||||
.drawflow .drawflow-node .output {
|
||||
right: 0 !important;
|
||||
top: 0 !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Port dots */
|
||||
.drawflow .drawflow-node .input,
|
||||
.drawflow .drawflow-node .output {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #94a3b8;
|
||||
background: white;
|
||||
position: relative;
|
||||
}
|
||||
.drawflow .drawflow-node .input:hover,
|
||||
.drawflow .drawflow-node .output:hover {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Output port labels (below each port) */
|
||||
.drawflow .drawflow-node .output[data-label]::after {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 14px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
color: #1e40af;
|
||||
background: #dbeafe;
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark #drawflow {
|
||||
background-color: #111827;
|
||||
background-image: radial-gradient(circle, #1f2937 1px, transparent 1px);
|
||||
}
|
||||
|
||||
.dark .drawflow .drawflow-node {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.dark .df-node-header {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.dark .df-node-url {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.dark .df-node-actions {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
.dark .drawflow .connection .main-path {
|
||||
stroke: #6b7280;
|
||||
}
|
||||
|
||||
.dark .drawflow .drawflow-node .input,
|
||||
.dark .drawflow .drawflow-node .output {
|
||||
border-color: #6b7280;
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.dark .drawflow .drawflow-node .output[data-label]::after {
|
||||
color: #93c5fd;
|
||||
background: #1e3a5f;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function initFlowEditor() {
|
||||
const container = document.getElementById('drawflow');
|
||||
if (!container || container._dfInit) return;
|
||||
container._dfInit = true;
|
||||
|
||||
const editor = new Drawflow(container);
|
||||
editor.reroute = false;
|
||||
editor.curvature = 0.5;
|
||||
editor.start();
|
||||
|
||||
// Override path rendering for vertical (top-down) flow
|
||||
editor.createCurvature = function(start_x, start_y, end_x, end_y, curvature, type) {
|
||||
const dy = Math.abs(end_y - start_y);
|
||||
const offsetY = Math.max(dy * curvature, 50);
|
||||
return ` M ${start_x} ${start_y} C ${start_x} ${start_y + offsetY} ${end_x} ${end_y - offsetY} ${end_x} ${end_y}`;
|
||||
};
|
||||
|
||||
window._dfEditor = editor;
|
||||
|
||||
const nodes = @js($this-> nodes);
|
||||
const edges = @js($this-> edges);
|
||||
const pageIdToNodeId = {};
|
||||
|
||||
// Add nodes - output count = options.length or 1 (default)
|
||||
nodes.forEach(node => {
|
||||
const numOutputs = (node.options && node.options.length > 0) ? node.options.length : 1;
|
||||
|
||||
const html = `
|
||||
<div class="df-node-content">
|
||||
<div class="df-node-header">${node.title}</div>
|
||||
<div class="df-node-url">${node.html_url}</div>
|
||||
${node.is_entry ? '<div><span class="df-node-badge">入口</span></div>' : ''}
|
||||
<div class="df-node-actions">
|
||||
<button onclick="event.stopPropagation(); Livewire.find('${@js($this->getId())}').mountAction('editPage', { id: ${node.id} })">编辑</button>
|
||||
<button class="btn-danger" onclick="event.stopPropagation(); Livewire.find('${@js($this->getId())}').mountAction('deletePage', { id: ${node.id} })">删除</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const nodeId = editor.addNode(
|
||||
'page', 1, numOutputs,
|
||||
node.x, node.y,
|
||||
node.is_entry ? 'entry-node' : '', {
|
||||
pageId: node.id,
|
||||
options: node.options || []
|
||||
},
|
||||
html
|
||||
);
|
||||
pageIdToNodeId[node.id] = nodeId;
|
||||
|
||||
// Label output ports with option names
|
||||
if (node.options && node.options.length > 0) {
|
||||
setTimeout(() => {
|
||||
const nodeEl = container.querySelector(`#node-${nodeId}`);
|
||||
if (!nodeEl) return;
|
||||
const outputs = nodeEl.querySelectorAll('.output');
|
||||
outputs.forEach((el, i) => {
|
||||
if (node.options[i]) {
|
||||
el.setAttribute('data-label', node.options[i]);
|
||||
el.title = node.options[i];
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Add edges - map to correct output port based on label
|
||||
edges.forEach(edge => {
|
||||
const fromNodeId = pageIdToNodeId[edge.from];
|
||||
const toNodeId = pageIdToNodeId[edge.to];
|
||||
if (!fromNodeId || !toNodeId) return;
|
||||
|
||||
// Find which output port matches this edge's label
|
||||
const fromNode = nodes.find(n => n.id === edge.from);
|
||||
let outputClass = 'output_1';
|
||||
if (fromNode && fromNode.options && fromNode.options.length > 0 && edge.label) {
|
||||
const idx = fromNode.options.indexOf(edge.label);
|
||||
if (idx >= 0) {
|
||||
outputClass = `output_${idx + 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
editor.addConnection(fromNodeId, toNodeId, outputClass, 'input_1');
|
||||
});
|
||||
|
||||
// Events → Livewire
|
||||
let ignoreEvents = false;
|
||||
|
||||
editor.on('connectionCreated', (info) => {
|
||||
if (ignoreEvents) return;
|
||||
const fromData = editor.getNodeFromId(info.output_id);
|
||||
const toData = editor.getNodeFromId(info.input_id);
|
||||
if (fromData && toData) {
|
||||
@this.call('addEdge', fromData.data.pageId, toData.data.pageId, info.output_class);
|
||||
}
|
||||
});
|
||||
|
||||
editor.on('connectionRemoved', (info) => {
|
||||
if (ignoreEvents) return;
|
||||
const fromData = editor.getNodeFromId(info.output_id);
|
||||
const toData = editor.getNodeFromId(info.input_id);
|
||||
if (fromData && toData) {
|
||||
@this.call('removeEdge', fromData.data.pageId, toData.data.pageId);
|
||||
}
|
||||
});
|
||||
|
||||
// Livewire graph refresh
|
||||
Livewire.on('graphUpdated', () => {
|
||||
ignoreEvents = true;
|
||||
container._dfInit = false;
|
||||
editor.clear();
|
||||
setTimeout(() => {
|
||||
container._dfInit = false;
|
||||
container.innerHTML = '';
|
||||
window._dfEditor = null;
|
||||
initFlowEditor();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load and SPA navigation
|
||||
document.addEventListener('DOMContentLoaded', () => setTimeout(initFlowEditor, 50));
|
||||
document.addEventListener('livewire:navigated', () => setTimeout(initFlowEditor, 50));
|
||||
</script>
|
||||
</x-filament-panels::page>
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user