fix: 优化指引页面复制与流程图布局
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
@@ -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 ? '<div><span class="df-node-badge">入口</span></div>' : ''}
|
||||
<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('copyPage',{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>
|
||||
</div></div>`;
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user