XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEWorkflow

【WinUI】XFE基于windows的工作流

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEWorkflow

修复连线圆头及重叠节点卡片的遮挡层级

2c3a1da
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

7 个文件 +128 -10
Modified XFEWorkflow/XFEWorkflow/Controls/WorkflowCanvas.Smoke.cs +44 -0
@@ -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 };
Added XFEWorkflow/XFEWorkflow/Controls/WorkflowCanvas.Wires.cs +50 -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 }
Modified XFEWorkflow/XFEWorkflow/Controls/WorkflowCanvas.cs +25 -8
@@ -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 {
Modified XFEWorkflow/XFEWorkflow/Services/UiSmokeTest.cs +1 -0
@@ -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));
Modified XFEWorkflow/XFEWorkflow/Views/MainPage.Workbench.Smoke.cs +4 -2
@@ -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 }
Modified docs/acceptance.md +2 -0
@@ -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 消息及错误反馈。
Modified docs/workbench.md +2 -0
@@ -18,6 +18,8 @@
18 18
19 19 插件、运行记录、日志和浏览器的主视图与详情之间使用可拖动分隔条,宽窗口左右排列,窄窗口上下排列;尺寸会保存。工作流编辑区继续保留导航、节点库、属性和输出面板的拖动分隔条。端口为圆环,连线接在圆心;按住端口拖出预览,松开到匹配端口后连接,Esc 或松开到空白处取消。可从输入端反向拖线,类型与环路校验继续生效。
20 20
21 连线和拖动预览位于卡片下层,重叠卡片会遮住连线;端口到自身卡片边界的短线与卡片一起绘制,保持接入圆心。线段端头及预览虚线断点使用圆形线帽。
22
21 23 ## 触发器配置
22 24
23 25 点击“配置触发器”,可添加多条规则并逐条启用/停用: