diff --git a/app/Filament/Resources/GuideResource/Pages/ManageGuidePages.php b/app/Filament/Resources/GuideResource/Pages/ManageGuidePages.php index dcce43e..5df5739 100644 --- a/app/Filament/Resources/GuideResource/Pages/ManageGuidePages.php +++ b/app/Filament/Resources/GuideResource/Pages/ManageGuidePages.php @@ -51,6 +51,18 @@ class ManageGuidePages extends Page } } + $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 = []; @@ -91,11 +103,80 @@ class ManageGuidePages extends Page } 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 = 30; // vertical gap within same level + $gapY = 60; // vertical gap within same level $positions = []; foreach ($levelGroups as $level => $ids) { @@ -229,6 +310,29 @@ class ManageGuidePages extends Page }); } + 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') diff --git a/resources/views/filament/resources/guide/manage-pages.blade.php b/resources/views/filament/resources/guide/manage-pages.blade.php index 74b8445..73f1a68 100644 --- a/resources/views/filament/resources/guide/manage-pages.blade.php +++ b/resources/views/filament/resources/guide/manage-pages.blade.php @@ -74,14 +74,20 @@ .df-node-actions { margin-top: 5px; - display: flex; - gap: 6px; + display: flex !important; + flex-direction: row !important; + flex-wrap: nowrap; + align-items: center; + gap: 5px; border-top: 1px solid #f3f4f6; padding-top: 5px; } .df-node-actions button, .df-node-actions a { - font-size: 10px; + display: inline-flex; + width: auto !important; + font-size: 9px; + line-height: 1.2; color: #6b7280; cursor: pointer; background: none; @@ -89,6 +95,7 @@ padding: 1px 0; text-decoration: none; transition: color .15s; + white-space: nowrap; } .df-node-actions button:hover, .df-node-actions a:hover { color: #3b82f6; } .df-node-actions button.btn-danger:hover { color: #ef4444; } @@ -107,13 +114,15 @@ } /* Edge label */ - .drawflow .connection .connection-label { + .drawflow .connection-label { font-size: 10px; fill: #1e40af; font-weight: 500; pointer-events: none; + dominant-baseline: middle; + text-anchor: middle; } - .drawflow .connection .connection-label-bg { + .drawflow .connection-label-bg { fill: #dbeafe; rx: 3; pointer-events: none; @@ -165,6 +174,7 @@ pointer-events: none; line-height: 1.6; } + .drawflow .drawflow-node .output[data-label].is-connected::after { content: none; } /* ── Dark mode ── */ .dark #drawflow { @@ -177,6 +187,8 @@ .dark .drawflow .connection .main-path { stroke: #6b7280; } .dark .drawflow .drawflow-node .input, .dark .drawflow .drawflow-node .output { border-color: #6b7280; background: #1f2937; } + .dark .drawflow .connection-label { fill: #93c5fd; } + .dark .drawflow .connection-label-bg { fill: #1e3a5f; } .dark .drawflow .drawflow-node .output[data-label]::after { color: #93c5fd; background: #1e3a5f; } @@ -203,10 +215,72 @@ document.body.appendChild(svg); } + function renderConnectionLabels(edges) { + const container = document.getElementById('drawflow'); + if (!container) return; + + container.querySelectorAll('.connection-label, .connection-label-bg').forEach(el => el.remove()); + + const paths = Array.from(container.querySelectorAll('.connection .main-path')); + paths.forEach((path, index) => { + const label = edges[index]?.label; + if (!label) return; + + const svg = path.ownerSVGElement; + if (!svg || typeof path.getTotalLength !== 'function') return; + + const point = path.getPointAtLength(path.getTotalLength() / 2); + const labelText = String(label); + const labelWidth = Math.max(28, labelText.length * 12 + 12); + + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute('class', 'connection-label-bg'); + rect.setAttribute('x', point.x - labelWidth / 2); + rect.setAttribute('y', point.y - 10); + rect.setAttribute('width', labelWidth); + rect.setAttribute('height', 20); + + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + text.setAttribute('class', 'connection-label'); + text.setAttribute('x', point.x); + text.setAttribute('y', point.y); + text.textContent = labelText; + + svg.appendChild(rect); + svg.appendChild(text); + }); + } + + function scheduleConnectionLabelsRender(edges = window._dfCurrentEdges ?? []) { + cancelAnimationFrame(window._dfLabelFrame); + window._dfLabelFrame = requestAnimationFrame(() => renderConnectionLabels(edges)); + } + + function markConnectedOutputs(edges, pageIdToNodeId) { + const container = document.getElementById('drawflow'); + if (!container) return; + + container.querySelectorAll('.output.is-connected').forEach(el => el.classList.remove('is-connected')); + + edges.forEach(edge => { + if (!edge.label) return; + + const fromNodeId = pageIdToNodeId[edge.from]; + const node = window._dfCurrentNodes?.find(item => item.id === edge.from); + const outputIndex = node?.options?.indexOf(edge.label) ?? -1; + if (!fromNodeId || outputIndex < 0) return; + + const output = container.querySelector(`#node-${fromNodeId} .output_${outputIndex + 1}`); + output?.classList.add('is-connected'); + }); + } + // ── Build / rebuild the Drawflow graph ─────────────────────────────── function buildGraph(nodes, edges) { const container = document.getElementById('drawflow'); if (!container) return; + window._dfCurrentNodes = nodes; + window._dfCurrentEdges = edges; // 销毁旧实例 if (window._dfEditor) { @@ -240,6 +314,7 @@ ${node.is_entry ? '
入口
' : ''}
+ 预览
`; @@ -283,6 +358,16 @@ try { editor.addConnection(fromNodeId, toNodeId, outputClass, 'input_1'); } catch(e) {} }); + markConnectedOutputs(edges, pageIdToNodeId); + + scheduleConnectionLabelsRender(edges); + + editor.on('nodeMoved', () => scheduleConnectionLabelsRender(edges)); + editor.on('translate', () => scheduleConnectionLabelsRender(edges)); + editor.on('zoom', () => scheduleConnectionLabelsRender(edges)); + container.addEventListener('mouseup', () => scheduleConnectionLabelsRender()); + container.addEventListener('touchend', () => scheduleConnectionLabelsRender()); + // Drawflow 事件 → Livewire(只在本次 editor 实例上绑定) editor.on('connectionCreated', info => { const from = editor.getNodeFromId(info.output_id); @@ -290,6 +375,7 @@ if (from && to) { Livewire.find(componentId).call('addEdge', from.data.pageId, to.data.pageId, info.output_class); } + scheduleConnectionLabelsRender(edges); }); editor.on('connectionRemoved', info => { @@ -298,6 +384,7 @@ if (from && to) { Livewire.find(componentId).call('removeEdge', from.data.pageId, to.data.pageId); } + scheduleConnectionLabelsRender(edges); }); }