返回提交历史
Modified
XFEWorkflow/XFEWorkflow/Controls/WorkflowCanvas.Smoke.cs
+44
-0
Added
XFEWorkflow/XFEWorkflow/Controls/WorkflowCanvas.Wires.cs
+50
-0
Modified
XFEWorkflow/XFEWorkflow/Controls/WorkflowCanvas.cs
+25
-8
Modified
XFEWorkflow/XFEWorkflow/Services/UiSmokeTest.cs
+1
-0
Modified
XFEWorkflow/XFEWorkflow/Views/MainPage.Workbench.Smoke.cs
+4
-2
Modified
docs/acceptance.md
+2
-0
Modified
docs/workbench.md
+2
-0
XFEstudio/XFEWorkflow
修复连线圆头及重叠节点卡片的遮挡层级
2c3a1da
代码差异
7 个文件
+128
-10
@@ -1,4 +1,7 @@
1
1
using Microsoft.UI.Xaml.Media;
2
using Microsoft.UI.Xaml.Media.Imaging;
3
using System.Runtime.InteropServices.WindowsRuntime;
4
using Windows.Foundation;
2
5
using XFEWorkflow.Abstractions;
3
6
using XFEWorkflow.Core.Runtime;
4
7
using XFEWorkflow.Services;
@@ -8,6 +11,47 @@ namespace XFEWorkflow.Controls;
8
11
9
12
public sealed partial class WorkflowCanvas
10
13
{
14
internal async Task SmokeWirePresentationAsync(string output)
15
{
16
var start = new NodeDefinition { CapabilityId = "core.start", Name = "开始", X = 0, Y = 0 };
17
var end = new NodeDefinition { CapabilityId = "core.end", Name = "结束", X = 650, Y = 0 };
18
var cover = new NodeDefinition { CapabilityId = "core.delay", Name = "覆盖", X = 300, Y = 0 };
19
SetGraph(new() { Nodes = [start, end, cover], Edges = [new() { FromNode = start.Id, ToNode = end.Id }] });
20
await Task.Delay(200); CenterView(true); await Task.Delay(180); DrawEdges();
21
var from = portElements.Single(x => x.Node == start.Id && x.Port == "next");
22
var to = portElements.Single(x => x.Node == end.Id && x.Port == "in");
23
var a = PortCenter(from); var b = PortCenter(to);
24
var wireColor = ((SolidColorBrush)from.Ring.Stroke).Color;
25
async Task AssertPixel(Point world, Windows.UI.Color expected, string message, int tolerance = 8)
26
{
27
var bitmap = new RenderTargetBitmap(); await bitmap.RenderAsync(this); var pixels = (await bitmap.GetPixelsAsync()).ToArray();
28
var point = surface.TransformToVisual(this).TransformPoint(world);
29
var x = (int)Math.Round(point.X * bitmap.PixelWidth / ActualWidth); var y = (int)Math.Round(point.Y * bitmap.PixelHeight / ActualHeight);
30
if (x < 0 || y < 0 || x >= bitmap.PixelWidth || y >= bitmap.PixelHeight) throw new InvalidOperationException("连线像素检查点不在视口内");
31
var offset = (y * bitmap.PixelWidth + x) * 4;
32
if (Math.Abs(pixels[offset] - expected.B) > tolerance || Math.Abs(pixels[offset + 1] - expected.G) > tolerance || Math.Abs(pixels[offset + 2] - expected.R) > tolerance)
33
throw new InvalidOperationException($"{message}:RGB={pixels[offset + 2]},{pixels[offset + 1]},{pixels[offset]}");
34
}
35
void MoveCover(double x, double y) { cover.X = x; cover.Y = y; Canvas.SetLeft(cardVisuals[cover.Id], x + OriginX); Canvas.SetTop(cardVisuals[cover.Id], y + OriginY); DrawEdges(); }
36
if (lines.Any(x => x.StrokeStartLineCap != PenLineCap.Round || x.StrokeEndLineCap != PenLineCap.Round)) throw new InvalidOperationException("连线端头没有使用圆形线帽");
37
// The endpoint's round cap extends into the ring instead of stopping
38
// flat at its center; card-local terminals keep it visible there.
39
await AssertPixel(new(a.X - .5, a.Y), wireColor, "圆环内部没有圆头连线", 75);
40
var coveredPoint = new Point(OriginX + cover.X + 150, a.Y);
41
MoveCover(300, a.Y - OriginY - 20); await Task.Delay(100);
42
var header = Microsoft.UI.ColorHelper.FromArgb(255, 44, 53, 70);
43
await AssertPixel(coveredPoint, header, "连线悬浮在遮挡卡片上方");
44
wireDrag = from; DrawWirePreview(b);
45
if (wirePreview!.StrokeDashCap != PenLineCap.Round) throw new InvalidOperationException("预览虚线断点没有使用圆头");
46
await AssertPixel(coveredPoint, header, "拖线预览悬浮在遮挡卡片上方"); CancelWire();
47
await UiSmokeTest.CaptureWindowAsync(System.IO.Path.Combine(output, "wire-below-overlapping-card.png"));
48
MoveCover(a.X - OriginX - 100, a.Y - OriginY - 20); await Task.Delay(100);
49
await AssertPixel(a, header, "被卡片覆盖的端口连接仍然悬浮显示");
50
await UiSmokeTest.CaptureWindowAsync(System.IO.Path.Combine(output, "wire-covered-port.png"));
51
MoveCover(300, 260); await Task.Delay(100);
52
await AssertPixel(coveredPoint, wireColor, "移开卡片后连线没有恢复显示", 30);
53
await UiSmokeTest.CaptureWindowAsync(System.IO.Path.Combine(output, "wire-rounded-terminals.png"));
54
}
11
55
internal async Task SmokeWiresAsync(string output)
12
56
{
13
57
var start = new NodeDefinition { CapabilityId = "core.start", Name = "开始", X = 0, Y = 0 };
@@ -0,0 +1,50 @@
1
using Microsoft.UI.Xaml.Media;
2
using Microsoft.UI.Xaml.Shapes;
3
using Windows.Foundation;
4
using PathShape = Microsoft.UI.Xaml.Shapes.Path;
5
6
namespace XFEWorkflow.Controls;
7
8
public sealed partial class WorkflowCanvas
9
{
10
// Only the curve between a port and its own card boundary belongs to the
11
// card. The rest stays below every card, including during a drag preview.
12
private PathShape? AddPortTerminal(PortAnchor port, PathGeometry geometry, PathShape style)
13
{
14
var layer = cardWires[port.Node];
15
if (layer.ActualWidth <= 0 || layer.ActualHeight <= 0) return null;
16
var transform = surface.TransformToVisual(layer);
17
var curve = (BezierSegment)geometry.Figures[0].Segments[0];
18
var a = transform.TransformPoint(geometry.Figures[0].StartPoint); var b = transform.TransformPoint(curve.Point1);
19
var c = transform.TransformPoint(curve.Point2); var d = transform.TransformPoint(curve.Point3);
20
var bounds = new Rect(0, 0, layer.ActualWidth, layer.ActualHeight);
21
static Point Mix(Point x, Point y, double t) => new(x.X + (y.X - x.X) * t, x.Y + (y.Y - x.Y) * t);
22
Point At(double t) => Mix(Mix(Mix(a, b, t), Mix(b, c, t), t), Mix(Mix(b, c, t), Mix(c, d, t), t), t);
23
double Parameter(double distance) => port.Output ? distance : 1 - distance;
24
var inside = 0d; var outside = 1d;
25
for (var i = 1; i <= 64; i++)
26
{
27
var distance = i / 64d;
28
if (!bounds.Contains(At(Parameter(distance)))) { outside = distance; break; }
29
inside = distance;
30
}
31
for (var i = 0; i < 24; i++)
32
{
33
var middle = (inside + outside) / 2;
34
if (bounds.Contains(At(Parameter(middle)))) inside = middle; else outside = middle;
35
}
36
var t = Parameter((inside + outside) / 2);
37
var ab = Mix(a, b, t); var bc = Mix(b, c, t); var cd = Mix(c, d, t);
38
var abc = Mix(ab, bc, t); var bcd = Mix(bc, cd, t); var endpoint = Mix(abc, bcd, t);
39
var figure = new PathFigure { StartPoint = port.Output ? a : endpoint };
40
figure.Segments.Add(new BezierSegment { Point1 = port.Output ? ab : bcd, Point2 = port.Output ? abc : cd, Point3 = port.Output ? endpoint : d });
41
var terminal = new PathShape { Data = new PathGeometry { Figures = { figure } }, Stroke = style.Stroke, StrokeThickness = style.StrokeThickness,
42
StrokeStartLineCap = PenLineCap.Round, StrokeEndLineCap = PenLineCap.Round, StrokeLineJoin = PenLineJoin.Round,
43
StrokeDashCap = PenLineCap.Round, Opacity = style.Opacity, IsHitTestVisible = false };
44
layer.Clip = new RectangleGeometry { Rect = bounds }; layer.Children.Add(terminal); return terminal;
45
}
46
private void AddPreviewTerminal(PortAnchor port, PathGeometry geometry)
47
{ if (wirePreview is not null && AddPortTerminal(port, geometry, wirePreview) is { } path) previewTerminals.Add((cardWires[port.Node], path)); }
48
private void ClearPreviewTerminals()
49
{ foreach (var (layer, path) in previewTerminals) layer.Children.Remove(path); previewTerminals.Clear(); }
50
}
@@ -20,6 +20,9 @@ public sealed partial class WorkflowCanvas : UserControl
20
20
HorizontalScrollMode = ScrollMode.Enabled, VerticalScrollMode = ScrollMode.Enabled,
21
21
HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden, VerticalScrollBarVisibility = ScrollBarVisibility.Hidden };
22
22
private readonly Dictionary<string, Border> cards = [];
23
private readonly Dictionary<string, Grid> cardVisuals = [];
24
private readonly Dictionary<string, Canvas> cardWires = [];
25
private readonly List<(Canvas Layer, PathShape Path)> previewTerminals = [];
23
26
private readonly HashSet<string> selected = [];
24
27
private readonly Stack<string> undo = [], redo = [];
25
28
private readonly List<PathShape> lines = [];
@@ -29,6 +32,7 @@ public sealed partial class WorkflowCanvas : UserControl
29
32
private PortAnchor? wireDrag;
30
33
private PathShape? wirePreview;
31
34
private Point wireOrigin;
35
private Point wirePointer;
32
36
private bool wireMoved;
33
37
private Point dragOrigin;
34
38
private Dictionary<string, Point>? dragging;
@@ -101,7 +105,7 @@ public sealed partial class WorkflowCanvas : UserControl
101
105
var node = Graph.Nodes.Single(x => x.Id == pair.Key);
102
106
node.X = Math.Clamp(pair.Value.X + point.X - dragOrigin.X, -OriginX + 40, OriginX - CardWidth - 40);
103
107
node.Y = Math.Clamp(pair.Value.Y + point.Y - dragOrigin.Y, -OriginY + 40, OriginY - 600);
104
Canvas.SetLeft(cards[node.Id], node.X + OriginX); Canvas.SetTop(cards[node.Id], node.Y + OriginY);
108
Canvas.SetLeft(cardVisuals[node.Id], node.X + OriginX); Canvas.SetTop(cardVisuals[node.Id], node.Y + OriginY);
105
109
}
106
110
DrawEdges();
107
111
}
@@ -266,7 +270,7 @@ public sealed partial class WorkflowCanvas : UserControl
266
270
}
267
271
public void Refresh()
268
272
{
269
CancelWire(); portElements.Clear(); surface.Children.Clear(); cards.Clear(); lines.Clear();
273
CancelWire(); portElements.Clear(); surface.Children.Clear(); cards.Clear(); cardVisuals.Clear(); cardWires.Clear(); lines.Clear();
270
274
surface.Background = new SolidColorBrush(ColorHelper.FromArgb(255, 24, 28, 36));
271
275
var grid = new PathGeometry();
272
276
for (var x = 0; x < WorldWidth; x += 40) { var figure = new PathFigure { StartPoint = new(x, 0) }; figure.Segments.Add(new LineSegment { Point = new(x, WorldHeight) }); grid.Figures.Add(figure); }
@@ -303,7 +307,12 @@ public sealed partial class WorkflowCanvas : UserControl
303
307
}
304
308
body.Children.Add(new Border { Height = 8 });
305
309
var card = new Border { Width = CardWidth, Child = body, CornerRadius = new(10), BorderThickness = new(1), Background = new SolidColorBrush(ColorHelper.FromArgb(255, 33, 39, 51)) };
306
cards[node.Id] = card; Canvas.SetLeft(card, node.X + OriginX); Canvas.SetTop(card, node.Y + OriginY); Canvas.SetZIndex(card, 2); surface.Children.Add(card);
310
var visual = new Grid { Width = CardWidth };
311
var terminals = new Canvas { IsHitTestVisible = false };
312
visual.Children.Add(card); visual.Children.Add(terminals);
313
cards[node.Id] = card; cardVisuals[node.Id] = visual; cardWires[node.Id] = terminals;
314
Canvas.SetLeft(visual, node.X + OriginX); Canvas.SetTop(visual, node.Y + OriginY); Canvas.SetZIndex(visual, 2); surface.Children.Add(visual);
315
visual.SizeChanged += (_, _) => DrawEdges();
307
316
heading.PointerPressed += (_, e) =>
308
317
{
309
318
if (!e.GetCurrentPoint(surface).Properties.IsLeftButtonPressed || KeyPressed(VirtualKey.Space)) return;
@@ -381,20 +390,24 @@ public sealed partial class WorkflowCanvas : UserControl
381
390
private void DrawWirePreview(Point point)
382
391
{
383
392
if (wireDrag is not { } from) return;
393
wirePointer = point; ClearPreviewTerminals();
384
394
var target = FindPort(point, from);
385
395
foreach (var port in portElements) port.Ring.StrokeThickness = port == target ? 3 : 2;
386
396
if (wirePreview is null)
387
397
{
388
wirePreview = new PathShape { IsHitTestVisible = false, StrokeThickness = 3, Opacity = .8, Stroke = from.Ring.Stroke, StrokeDashArray = new DoubleCollection { 3, 2 } };
389
surface.Children.Add(wirePreview); Canvas.SetZIndex(wirePreview, 20);
398
wirePreview = new PathShape { IsHitTestVisible = false, StrokeThickness = 3, Opacity = .8, Stroke = from.Ring.Stroke, StrokeDashArray = new DoubleCollection { 3, 2 },
399
StrokeStartLineCap = PenLineCap.Round, StrokeEndLineCap = PenLineCap.Round, StrokeDashCap = PenLineCap.Round, StrokeLineJoin = PenLineJoin.Round };
400
surface.Children.Add(wirePreview); Canvas.SetZIndex(wirePreview, 1);
390
401
}
391
402
var end = target is null ? point : PortCenter(target);
392
wirePreview.Data = from.Output ? WireGeometry(PortCenter(from), end) : WireGeometry(end, PortCenter(from));
403
var geometry = from.Output ? WireGeometry(PortCenter(from), end) : WireGeometry(end, PortCenter(from)); wirePreview.Data = geometry;
404
AddPreviewTerminal(from, geometry); if (target is not null) AddPreviewTerminal(target, geometry);
393
405
}
394
406
private void CancelWire()
395
407
{
396
408
wireDrag = null; wireMoved = false;
397
409
if (wirePreview is not null) surface.Children.Remove(wirePreview); wirePreview = null;
410
ClearPreviewTerminals();
398
411
foreach (var port in portElements) port.Ring.StrokeThickness = 2;
399
412
}
400
413
private void EndPointer(PointerRoutedEventArgs e)
@@ -412,6 +425,7 @@ public sealed partial class WorkflowCanvas : UserControl
412
425
private void DrawEdges()
413
426
{
414
427
foreach (var line in lines) surface.Children.Remove(line); lines.Clear();
428
ClearPreviewTerminals(); foreach (var layer in cardWires.Values) layer.Children.Clear();
415
429
foreach (var edge in Graph.Edges)
416
430
{
417
431
var from = Graph.Nodes.FirstOrDefault(x => x.Id == edge.FromNode); var to = Graph.Nodes.FirstOrDefault(x => x.Id == edge.ToNode);
@@ -420,10 +434,13 @@ public sealed partial class WorkflowCanvas : UserControl
420
434
var target = portElements.FirstOrDefault(x => x.Node == to.Id && !x.Output && x.Port == edge.ToPort);
421
435
if (source?.Ring.IsLoaded != true || target?.Ring.IsLoaded != true) continue;
422
436
var geometry = WireGeometry(PortCenter(source), PortCenter(target));
423
var line = new PathShape { Data = geometry, StrokeThickness = 3, Stroke = new SolidColorBrush(edge.Kind == PortKind.Execution ? Colors.LightSkyBlue : Colors.MediumAquamarine) };
437
var line = new PathShape { Data = geometry, StrokeThickness = 3, Stroke = new SolidColorBrush(edge.Kind == PortKind.Execution ? Colors.LightSkyBlue : Colors.MediumAquamarine),
438
StrokeStartLineCap = PenLineCap.Round, StrokeEndLineCap = PenLineCap.Round, StrokeLineJoin = PenLineJoin.Round };
424
439
line.RightTapped += (_, e) => { Checkpoint(); Graph.Edges.Remove(edge); DrawEdges(); Changed?.Invoke(Graph); e.Handled = true; };
425
ToolTipService.SetToolTip(line, edge.FromPort + " → " + edge.ToPort + "(右键删除)"); surface.Children.Add(line); Canvas.SetZIndex(line, 3); lines.Add(line);
440
ToolTipService.SetToolTip(line, edge.FromPort + " → " + edge.ToPort + "(右键删除)"); surface.Children.Add(line); Canvas.SetZIndex(line, 1); lines.Add(line);
441
AddPortTerminal(source, geometry, line); AddPortTerminal(target, geometry, line);
426
442
}
443
if (wireDrag is not null) DrawWirePreview(wirePointer);
427
444
}
428
445
private void UpdateSelection()
429
446
{
@@ -49,6 +49,7 @@ internal static partial class UiSmokeTest
49
49
await Check("canvas-centered-mouse-pan-and-light-dark-theme", () => MainPage.Current!.SmokePanAsync(output));
50
50
await Check("canvas-wheel-zooms-at-pointer-without-control", () => MainPage.Current!.SmokeWheelAsync(output));
51
51
await Check("canvas-ring-centers-native-drag-preview-release-cancel-and-zoom", () => MainPage.Current!.SmokeWiresAsync(output));
52
await Check("canvas-rounded-wire-caps-card-occlusion-and-port-terminals", () => MainPage.Current!.SmokeWirePresentationAsync(output));
52
53
await Check("workflow-library-triggers-circular-badges-and-xfe-log-output", () => MainPage.Current!.SmokeWorkflowChromeAsync(output));
53
54
await Check("output-diagnostics-connected-tab-surface-light-dark", () => MainPage.Current!.SmokeConnectedTabsAsync(output));
54
55
await Check("management-plugin-runs-logs-settings-responsive-and-workflow-links", () => SmokeManagementPagesAsync(output));
@@ -58,14 +58,16 @@ public sealed partial class MainPage
58
58
}
59
59
finally { RunPanel.SelectedIndex = selected; root.RequestedTheme = theme; if (wasCollapsed) ToggleOutput_Click(this, new()); }
60
60
}
61
internal async Task SmokeWiresAsync(string output)
61
internal Task SmokeWiresAsync(string output) => SmokeCanvasAsync(output, CanvasEditor.SmokeWiresAsync);
62
internal Task SmokeWirePresentationAsync(string output) => SmokeCanvasAsync(output, CanvasEditor.SmokeWirePresentationAsync);
63
private async Task SmokeCanvasAsync(string output, Func<string, Task> verify)
62
64
{
63
65
await ready.Task; var previous = ViewModel.SelectedWorkflow; var size = App.MainWindow.AppWindow.Size;
64
66
try
65
67
{
66
68
var scale = XamlRoot.RasterizationScale; App.MainWindow.AppWindow.Resize(new((int)(1440 * scale), (int)(960 * scale)));
67
69
await ViewModel.NewAsync(WorkflowTemplates.CreateGraph()); PropertySplit.IsPaneOpen = false; WorkflowLibraryExpander.IsExpanded = false;
68
await Task.Delay(200); await CanvasEditor.SmokeWiresAsync(output);
70
await Task.Delay(200); await verify(output);
69
71
}
70
72
finally { if (previous is not null) ViewModel.SelectedWorkflow = previous; App.MainWindow.AppWindow.Resize(size); }
71
73
}
@@ -53,6 +53,8 @@ Windows x64 的 Debug / Release 构建均为零警告、零错误。XFE XUnit **
53
53
54
54
实际界面截图:[输出选项卡(深色)](screenshots/workbench-tabs-dark.png)、[诊断选项卡(浅色)](screenshots/workbench-tabs-light.png)、[端口与连线](screenshots/workbench-wire-rings.png)、[拖线预览](screenshots/workbench-wire-preview.png)、[插件中心](screenshots/workbench-plugins-dark.png)、[运行记录](screenshots/workbench-runs.png)、[工作流日志](screenshots/workbench-logs.png)、[凭据设置](screenshots/workbench-settings-credentials.png)、[桌面输入设置](screenshots/workbench-settings-input.png)。[设计图与使用说明](workbench.md)。
55
55
56
连线层级增量验收:Release x64 构建零警告、零错误;`.artifacts/ui-wire-layer/ui-results.json` 的 2/2 项原生检查通过。覆盖圆头端口连接、预览虚线圆头、重叠卡片及覆盖端口的像素检查、移开卡片后恢复连线,以及实际鼠标拖线、松开连接、取消、反向连接与缩放。
57
56
58
## 需在目标环境手动验收
57
59
58
60
1. 配置真实 AI 服务的地址、模型和凭据,运行 AI → 企微模板,检查群内 text/markdown 消息及错误反馈。
@@ -18,6 +18,8 @@
18
18
19
19
插件、运行记录、日志和浏览器的主视图与详情之间使用可拖动分隔条,宽窗口左右排列,窄窗口上下排列;尺寸会保存。工作流编辑区继续保留导航、节点库、属性和输出面板的拖动分隔条。端口为圆环,连线接在圆心;按住端口拖出预览,松开到匹配端口后连接,Esc 或松开到空白处取消。可从输入端反向拖线,类型与环路校验继续生效。
20
20
21
连线和拖动预览位于卡片下层,重叠卡片会遮住连线;端口到自身卡片边界的短线与卡片一起绘制,保持接入圆心。线段端头及预览虚线断点使用圆形线帽。
22
21
23
## 触发器配置
22
24
23
25
点击“配置触发器”,可添加多条规则并逐条启用/停用: