fix: 优化指引页面复制与流程图布局

This commit is contained in:
2026-04-24 14:18:33 +08:00
parent e935afddfe
commit 0e73a77b86
2 changed files with 197 additions and 6 deletions

View File

@@ -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 // BFS from entry nodes (no incoming edges) to assign levels
$hasIncoming = array_flip($edgeModels->pluck('to_page_id')->toArray()); $hasIncoming = array_flip($edgeModels->pluck('to_page_id')->toArray());
$levels = []; $levels = [];
@@ -91,11 +103,80 @@ class ManageGuidePages extends Page
} }
ksort($levelGroups); 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) // Compute positions: center each level vertically, stack horizontally (left-to-right)
$nodeWidth = 180; // matches CSS max-width $nodeWidth = 180; // matches CSS max-width
$nodeHeight = 80; // compact node height $nodeHeight = 80; // compact node height
$gapX = 110; // horizontal gap between levels $gapX = 110; // horizontal gap between levels
$gapY = 30; // vertical gap within same level $gapY = 60; // vertical gap within same level
$positions = []; $positions = [];
foreach ($levelGroups as $level => $ids) { 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 public function deletePageAction(): Action
{ {
return Action::make('deletePage') return Action::make('deletePage')

View File

@@ -74,14 +74,20 @@
.df-node-actions { .df-node-actions {
margin-top: 5px; margin-top: 5px;
display: flex; display: flex !important;
gap: 6px; flex-direction: row !important;
flex-wrap: nowrap;
align-items: center;
gap: 5px;
border-top: 1px solid #f3f4f6; border-top: 1px solid #f3f4f6;
padding-top: 5px; padding-top: 5px;
} }
.df-node-actions button, .df-node-actions button,
.df-node-actions a { .df-node-actions a {
font-size: 10px; display: inline-flex;
width: auto !important;
font-size: 9px;
line-height: 1.2;
color: #6b7280; color: #6b7280;
cursor: pointer; cursor: pointer;
background: none; background: none;
@@ -89,6 +95,7 @@
padding: 1px 0; padding: 1px 0;
text-decoration: none; text-decoration: none;
transition: color .15s; transition: color .15s;
white-space: nowrap;
} }
.df-node-actions button:hover, .df-node-actions a:hover { color: #3b82f6; } .df-node-actions button:hover, .df-node-actions a:hover { color: #3b82f6; }
.df-node-actions button.btn-danger:hover { color: #ef4444; } .df-node-actions button.btn-danger:hover { color: #ef4444; }
@@ -107,13 +114,15 @@
} }
/* Edge label */ /* Edge label */
.drawflow .connection .connection-label { .drawflow .connection-label {
font-size: 10px; font-size: 10px;
fill: #1e40af; fill: #1e40af;
font-weight: 500; font-weight: 500;
pointer-events: none; pointer-events: none;
dominant-baseline: middle;
text-anchor: middle;
} }
.drawflow .connection .connection-label-bg { .drawflow .connection-label-bg {
fill: #dbeafe; fill: #dbeafe;
rx: 3; rx: 3;
pointer-events: none; pointer-events: none;
@@ -165,6 +174,7 @@
pointer-events: none; pointer-events: none;
line-height: 1.6; line-height: 1.6;
} }
.drawflow .drawflow-node .output[data-label].is-connected::after { content: none; }
/* ── Dark mode ── */ /* ── Dark mode ── */
.dark #drawflow { .dark #drawflow {
@@ -177,6 +187,8 @@
.dark .drawflow .connection .main-path { stroke: #6b7280; } .dark .drawflow .connection .main-path { stroke: #6b7280; }
.dark .drawflow .drawflow-node .input, .dark .drawflow .drawflow-node .input,
.dark .drawflow .drawflow-node .output { border-color: #6b7280; background: #1f2937; } .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; } .dark .drawflow .drawflow-node .output[data-label]::after { color: #93c5fd; background: #1e3a5f; }
</style> </style>
@@ -203,10 +215,72 @@
document.body.appendChild(svg); 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 ─────────────────────────────── // ── Build / rebuild the Drawflow graph ───────────────────────────────
function buildGraph(nodes, edges) { function buildGraph(nodes, edges) {
const container = document.getElementById('drawflow'); const container = document.getElementById('drawflow');
if (!container) return; if (!container) return;
window._dfCurrentNodes = nodes;
window._dfCurrentEdges = edges;
// 销毁旧实例 // 销毁旧实例
if (window._dfEditor) { if (window._dfEditor) {
@@ -240,6 +314,7 @@
${node.is_entry ? '<div><span class="df-node-badge">入口</span></div>' : ''} ${node.is_entry ? '<div><span class="df-node-badge">入口</span></div>' : ''}
<div class="df-node-actions"> <div class="df-node-actions">
<button onclick="event.stopPropagation();Livewire.find('${componentId}').mountAction('editPage',{id:${node.id}})">编辑</button> <button onclick="event.stopPropagation();Livewire.find('${componentId}').mountAction('editPage',{id:${node.id}})">编辑</button>
<button onclick="event.stopPropagation();Livewire.find('${componentId}').mountAction('copyPage',{id:${node.id}})">复制</button>
<button class="btn-danger" onclick="event.stopPropagation();Livewire.find('${componentId}').mountAction('deletePage',{id:${node.id}})">删除</button> <button class="btn-danger" onclick="event.stopPropagation();Livewire.find('${componentId}').mountAction('deletePage',{id:${node.id}})">删除</button>
<a href="${escHtml(node.uri)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">预览</a> <a href="${escHtml(node.uri)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">预览</a>
</div></div>`; </div></div>`;
@@ -283,6 +358,16 @@
try { editor.addConnection(fromNodeId, toNodeId, outputClass, 'input_1'); } catch(e) {} 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 实例上绑定) // Drawflow 事件 → Livewire只在本次 editor 实例上绑定)
editor.on('connectionCreated', info => { editor.on('connectionCreated', info => {
const from = editor.getNodeFromId(info.output_id); const from = editor.getNodeFromId(info.output_id);
@@ -290,6 +375,7 @@
if (from && to) { if (from && to) {
Livewire.find(componentId).call('addEdge', from.data.pageId, to.data.pageId, info.output_class); Livewire.find(componentId).call('addEdge', from.data.pageId, to.data.pageId, info.output_class);
} }
scheduleConnectionLabelsRender(edges);
}); });
editor.on('connectionRemoved', info => { editor.on('connectionRemoved', info => {
@@ -298,6 +384,7 @@
if (from && to) { if (from && to) {
Livewire.find(componentId).call('removeEdge', from.data.pageId, to.data.pageId); Livewire.find(componentId).call('removeEdge', from.data.pageId, to.data.pageId);
} }
scheduleConnectionLabelsRender(edges);
}); });
} }