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

XFEWorkflow

【WinUI】XFE基于windows的工作流

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

XFEstudio/XFEWorkflow

重设计节点库并支持各面板拖动调整尺寸

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

代码差异

22 个文件 +769 -58
Modified README.md +4 -1
@@ -24,7 +24,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools/Publish-Release.ps1 -P
24 24
25 25 ## 使用
26 26
27 - 使用“新建工作流”,在顶部选择图形化或 C# 执行方式;切换时分别保留图形和代码。节点库按功能分类,并标注提供节点的插件名称。
27 - 使用“新建工作流”,在顶部选择图形化或 C# 执行方式;切换时分别保留图形和代码。节点库使用可折叠分类和紧凑列表,标注插件名称;支持收藏、最近使用和按插件筛选。Ctrl+K 搜索节点、功能或插件,多个关键词用空格分隔,Enter 添加首个结果、Esc 清空;搜索自动展开命中分类,清空后恢复原先的折叠状态。
28 - 拖动面板之间的分隔条调整节点库和属性区宽度、运行输出高度;展开的导航栏与浏览器拾取结果区也可拖动调整。尺寸按工作区保存,窗口缩小时限制到可用空间,放大后恢复偏好尺寸。分隔条可用方向键微调,双击或 Home 恢复默认值;工具栏更多菜单提供“重置面板布局”。
28 29 - 将节点拖到带边框的画布,点击端口连线。蓝色连接执行顺序,绿色连接数据;左侧节点库使用系统强调色并跟随浅/深色主题。
29 30 - 拖动空白处、按住空格拖动或中键拖动可平移;Shift 拖动框选、Shift 点击多选;直接滚动鼠标滚轮,以鼠标位置为中心缩放,“适应画布”查看全图。滚动条默认隐藏。
30 31 - Ctrl+C/V 复制粘贴,Ctrl+Z/Y 撤销重做,右键删除连线。窄窗口通过“节点属性”按钮展开属性面板;循环、并行与异常处理通过“编辑子图”组织。
@@ -39,6 +40,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools/Publish-Release.ps1 -P
39 40
40 41 工作区通过 AutoPath 放在应用本地数据目录的 `Workspace` 中;`XFE_WORKFLOW_DATA` 可指定独立工作区。工作流、运行记录、插件与浏览器配置分别保存,凭据使用当前 Windows 用户 DPAPI 加密。
41 42
43 节点库交互参考 [Node-RED 的分类折叠与搜索](https://nodered.org/docs/user-guide/editor/palette/)及 [Unreal Blueprint 的收藏、筛选与拖放](https://dev.epicgames.com/documentation/en-us/unreal-engine/palette-in-the-bleprints-visual-scripting-editor-for-unreal-engine),使用原生 WinUI 控件和系统强调色实现。画布内节点保留原有样式。
44
42 45 ## 模块
43 46
44 47 | 项目 | 职责 |
Modified XFEWorkflow/XFEWorkflow.Infrastructure/Storage/WorkspaceStorage.cs +22 -0
@@ -138,6 +138,10 @@ public partial class ClientStateProfile : XFEProfile
138 138 [ProfileProperty] private string _spaceSession = "";
139 139 [ProfileProperty] private string _spaceUserJson = "";
140 140 [ProfileProperty] private string _spaceUserAddress = "";
141 [ProfileProperty] private Dictionary<string, double> _paneSizes = [];
142 [ProfileProperty] private List<string> _favoriteCapabilities = [];
143 [ProfileProperty] private List<string> _recentCapabilities = [];
144 [ProfileProperty] private List<string> _expandedPaletteCategories = ["流程"];
141 145 }
142 146
143 147 public sealed class ClientState : IAsyncDisposable
@@ -152,6 +156,24 @@ public sealed class ClientState : IAsyncDisposable
152 156 public string SpaceAddress { get => Store.Read(p => p.InstanceSpaceAddress); set => Store.Update(p => p.InstanceSpaceAddress = value); }
153 157 public string DeviceId => Store.Read(p => p.InstanceDeviceId);
154 158 public string DesktopBackend { get => Store.Read(p => p.InstanceDesktopBackend); set => Store.Update(p => p.InstanceDesktopBackend = value); }
159 public double PaneSize(string key, double fallback) => Store.Read(p => p.InstancePaneSizes.TryGetValue(key, out var value) && double.IsFinite(value) && value > 0 ? value : fallback);
160 public void SetPaneSize(string key, double value)
161 {
162 if (!double.IsFinite(value) || value <= 0) throw new ArgumentOutOfRangeException(nameof(value));
163 Store.Update(p => p.InstancePaneSizes = new(p.InstancePaneSizes) { [key] = value });
164 }
165 public HashSet<string> FavoriteCapabilities => Store.Read(p => p.InstanceFavoriteCapabilities.ToHashSet(StringComparer.Ordinal));
166 public List<string> RecentCapabilities => Store.Read(p => p.InstanceRecentCapabilities.ToList());
167 public HashSet<string> ExpandedPaletteCategories => Store.Read(p => p.InstanceExpandedPaletteCategories.ToHashSet(StringComparer.Ordinal));
168 public void SetFavorite(string id, bool favorite) => Store.Update(p =>
169 {
170 var values = p.InstanceFavoriteCapabilities.ToHashSet(StringComparer.Ordinal);
171 if (favorite) values.Add(id); else values.Remove(id);
172 p.InstanceFavoriteCapabilities = values.Order(StringComparer.Ordinal).ToList();
173 });
174 public void RecordCapability(string id) => Store.Update(p => p.InstanceRecentCapabilities = new[] { id }
175 .Concat(p.InstanceRecentCapabilities.Where(x => x != id)).Take(12).ToList());
176 public void SetExpandedPaletteCategories(IEnumerable<string> names) => Store.Update(p => p.InstanceExpandedPaletteCategories = names.Distinct(StringComparer.Ordinal).ToList());
155 177 public Task SaveAsync(CancellationToken ct = default) => Store.SaveAsync(ct);
156 178 public async ValueTask DisposeAsync() { await Store.SaveAsync(); }
157 179 }
Added XFEWorkflow/XFEWorkflow.Test/EditorPreferencesTests.cs +42 -0
@@ -0,0 +1,42 @@
1 using XFEExtension.NetCore.XUnit.Assertions;
2 using XFEExtension.NetCore.XUnit.Attributes;
3 using XFEWorkflow.Infrastructure.Storage;
4
5 namespace XFEWorkflow.Test;
6
7 [TestFixture]
8 public class EditorPreferencesTests
9 {
10 [Test]
11 public async Task LayoutFavoritesAndRecentNodesSurviveRestartWithoutDuplicates()
12 {
13 await using var workspace = new TestWorkspace();
14 await using (var state = new ClientState(workspace.Paths))
15 {
16 state.SetPaneSize("workflow.palette", 324); state.SetPaneSize("browser.picker", 120);
17 state.SetFavorite("xfe.vision.ocr", true); state.SetFavorite("xfe.vision.ocr", true);
18 state.SetFavorite("missing.plugin.action", true); // Keep choices while a plugin is disabled.
19 state.SetFavorite("xfe.http.request", true); state.SetFavorite("xfe.http.request", false);
20 for (var i = 0; i < 15; i++) state.RecordCapability("fixture." + i);
21 state.RecordCapability("fixture.8");
22 state.SetExpandedPaletteCategories(["流程", "文字识别", "文字识别"]);
23 Assert.Throws<ArgumentOutOfRangeException>(() => state.SetPaneSize("workflow.palette", double.NaN));
24 Assert.Throws<ArgumentOutOfRangeException>(() => state.SetPaneSize("workflow.palette", -1));
25 await state.SaveAsync();
26 }
27 await using var restored = new ClientState(workspace.Paths);
28 Assert.Equal(324d, restored.PaneSize("workflow.palette", 260));
29 Assert.Equal(120d, restored.PaneSize("browser.picker", 64));
30 Assert.Equal(296d, restored.PaneSize("workflow.properties", 296));
31 Assert.Equal(2, restored.FavoriteCapabilities.Count);
32 Assert.True(restored.FavoriteCapabilities.Contains("missing.plugin.action"));
33 Assert.Equal(12, restored.RecentCapabilities.Count);
34 Assert.Equal("fixture.8", restored.RecentCapabilities[0]);
35 Assert.Equal("fixture.14", restored.RecentCapabilities[1]);
36 Assert.False(restored.RecentCapabilities.Contains("fixture.0"));
37 Assert.Equal(12, restored.RecentCapabilities.Distinct().Count());
38 Assert.Equal(2, restored.ExpandedPaletteCategories.Count);
39 restored.FavoriteCapabilities.Clear(); restored.RecentCapabilities.Clear();
40 Assert.Equal(2, restored.FavoriteCapabilities.Count); Assert.Equal(12, restored.RecentCapabilities.Count);
41 }
42 }
Modified XFEWorkflow/XFEWorkflow/App.xaml +1 -0
@@ -11,6 +11,7 @@
11 11 <ResourceDictionary Source="/Styles/Thickness.xaml" />
12 12 <ResourceDictionary Source="/Styles/Colors.xaml" />
13 13 <ResourceDictionary Source="/Styles/ConverterResources.xaml" />
14 <ResourceDictionary Source="/Styles/EditorControls.xaml" />
14 15 <!-- Other merged dictionaries here -->
15 16 </ResourceDictionary.MergedDictionaries>
16 17 <SolidColorBrush x:Key="WorkflowAccentBrush"/>
Modified XFEWorkflow/XFEWorkflow/Controls/BrowserHost.cs +15 -6
@@ -16,32 +16,41 @@ public sealed class BrowserHost : UserControl, IAsyncDisposable
16 16 private readonly Grid toolbar = new() { ColumnSpacing = 8 };
17 17 private readonly TextBox address = new() { PlaceholderText = "输入地址", HorizontalAlignment = HorizontalAlignment.Stretch };
18 18 private readonly TextBox profile = new() { Text = "default", Width = 130, PlaceholderText = "登录配置" };
19 private readonly TextBox picked = new() { IsReadOnly = true, PlaceholderText = "点击“拾取元素”,然后在网页中点击目标。iframe 路径与选择器会显示在这里。" };
19 private readonly TextBox picked = new() { IsReadOnly = true, TextWrapping = TextWrapping.Wrap, AcceptsReturn = true, PlaceholderText = "点击“拾取元素”,然后在网页中点击目标。iframe 路径与选择器会显示在这里。" };
20 private readonly PaneSplitter resultSplitter = new() { Orientation = Orientation.Horizontal };
21 private readonly RowDefinition pickedRow = new() { Height = new(64) };
20 22 private readonly Dictionary<string, BrowserTab> sessions = [];
21 23 private readonly Dictionary<string, CoreWebView2Environment> environments = [];
22 24 public WorkspacePaths? Paths { get; set; }
23 25 private BrowserTab? Current => tabs.SelectedItem is TabViewItem item ? sessions.Values.FirstOrDefault(x => x.Item == item) : null;
24 26 public BrowserHost()
25 27 {
26 var root = new Grid { RowSpacing = 8, Padding = new(12) };
27 root.RowDefinitions.Add(new() { Height = GridLength.Auto }); root.RowDefinitions.Add(new() { Height = GridLength.Auto }); root.RowDefinitions.Add(new() { Height = new GridLength(1, GridUnitType.Star) });
28 var root = new Grid { Padding = new(12) }; toolbar.Margin = new(0, 0, 0, 8);
29 root.RowDefinitions.Add(new() { Height = GridLength.Auto }); root.RowDefinitions.Add(pickedRow);
30 root.RowDefinitions.Add(new() { Height = new(8) }); root.RowDefinitions.Add(new() { Height = new GridLength(1, GridUnitType.Star) });
28 31 toolbar.ColumnDefinitions.Add(new() { Width = GridLength.Auto }); toolbar.ColumnDefinitions.Add(new());
29 32 toolbar.ColumnDefinitions.Add(new() { Width = GridLength.Auto }); toolbar.ColumnDefinitions.Add(new() { Width = GridLength.Auto }); toolbar.ColumnDefinitions.Add(new() { Width = GridLength.Auto });
30 33 var back = new Button { Content = "←" }; back.Click += (_, _) => { if (Current?.Web.CoreWebView2.CanGoBack == true) Current.Web.GoBack(); };
31 34 var go = new Button { Content = "前往" }; go.Click += async (_, _) => await UiGuard(async () => { if (Current is null) await OpenAsync("manual", profile.Text, address.Text); else await NavigateAsync(Current, address.Text, CancellationToken.None); });
32 35 var pick = new Button { Content = "拾取元素" }; pick.Click += async (_, _) => await UiGuard(async () => { if (Current is { } tab) await PickAsync(tab); });
33 36 toolbar.Children.Add(back); Grid.SetColumn(address, 1); toolbar.Children.Add(address); Grid.SetColumn(profile, 2); toolbar.Children.Add(profile); Grid.SetColumn(go, 3); toolbar.Children.Add(go); Grid.SetColumn(pick, 4); toolbar.Children.Add(pick);
34 root.Children.Add(toolbar); Grid.SetRow(picked, 1); root.Children.Add(picked); Grid.SetRow(tabs, 2); root.Children.Add(tabs); Content = root;
37 root.Children.Add(toolbar); Grid.SetRow(picked, 1); root.Children.Add(picked);
38 Grid.SetRow(resultSplitter, 2); root.Children.Add(resultSplitter); Grid.SetRow(tabs, 3); root.Children.Add(tabs); Content = root;
39 Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(resultSplitter, "调整浏览器拾取结果高度");
40 resultSplitter.Bind("browser.picker", 64, 36, 240, () => pickedRow.ActualHeight, value => pickedRow.Height = new(value), () => ActualHeight - toolbar.ActualHeight - 224);
41 resultSplitter.Notice += message => picked.Text = message;
35 42 tabs.AddTabButtonClick += async (_, _) => await UiGuard(async () => await OpenAsync("manual", profile.Text, "about:blank"));
36 43 tabs.TabCloseRequested += (_, e) => CloseTab(sessions.Values.Single(x => x.Item == e.Tab));
37 44 void Resize()
38 45 {
39 tabs.Height = Math.Max(168, ActualHeight - toolbar.ActualHeight - picked.ActualHeight - 40);
46 tabs.Height = Math.Max(168, ActualHeight - toolbar.ActualHeight - pickedRow.ActualHeight - 40);
40 47 foreach (var tab in sessions.Values) tab.Web.Height = Math.Max(120, tabs.Height - 48);
41 48 }
42 SizeChanged += (_, _) => Resize(); toolbar.SizeChanged += (_, _) => Resize(); picked.SizeChanged += (_, _) => Resize();
49 SizeChanged += (_, _) => { resultSplitter.ApplyPreferredSize(); Resize(); }; toolbar.SizeChanged += (_, _) => Resize(); picked.SizeChanged += (_, _) => Resize();
43 50 tabs.SelectionChanged += (_, _) => { if (Current is { } tab) address.Text = tab.Web.Source?.ToString() ?? ""; };
44 51 }
52 internal PaneSplitter ResultSplitterForSmoke => resultSplitter;
53 internal double ResultHeightForSmoke => pickedRow.ActualHeight;
45 54 private async Task UiGuard(Func<Task> operation) { try { await operation(); } catch (Exception ex) { picked.Text = ex.Message; } }
46 55 public Task<T> OnUIAsync<T>(Func<Task<T>> action)
47 56 {
Added XFEWorkflow/XFEWorkflow/Controls/PaletteTemplateSelector.cs +11 -0
@@ -0,0 +1,11 @@
1 using XFEWorkflow.ViewModels;
2
3 namespace XFEWorkflow.Controls;
4
5 public sealed class PaletteTemplateSelector : DataTemplateSelector
6 {
7 public DataTemplate CategoryTemplate { get; set; } = null!;
8 public DataTemplate NodeTemplate { get; set; } = null!;
9 protected override DataTemplate SelectTemplateCore(object item) => item is PaletteGroup ? CategoryTemplate : NodeTemplate;
10 protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) => SelectTemplateCore(item);
11 }
Added XFEWorkflow/XFEWorkflow/Controls/PaneSplitter.cs +89 -0
@@ -0,0 +1,89 @@
1 using Microsoft.UI.Input;
2 using Microsoft.UI.Xaml.Controls.Primitives;
3 using Microsoft.UI.Xaml.Input;
4 using Windows.System;
5 using XFEWorkflow.Services;
6
7 namespace XFEWorkflow.Controls;
8
9 /// <summary>A native mouse/touch/keyboard splitter. Window constraints never overwrite the preferred size.</summary>
10 public sealed class PaneSplitter : Control
11 {
12 public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register(nameof(Orientation), typeof(Orientation), typeof(PaneSplitter),
13 new PropertyMetadata(Orientation.Vertical, (sender, _) => ((PaneSplitter)sender).UpdateDirection()));
14 public Orientation Orientation { get => (Orientation)GetValue(OrientationProperty); set => SetValue(OrientationProperty, value); }
15 public bool Reverse { get; set; }
16 public double PreferredSize { get; private set; }
17 private Func<double>? getSize, available;
18 private Action<double>? setSize;
19 private string key = "";
20 private double minimum, maximum, fallback;
21 private Border? grip;
22 private Thumb? thumb;
23 private bool dragging;
24 public event Action<string>? Notice;
25 public PaneSplitter()
26 {
27 DefaultStyleKey = typeof(PaneSplitter); IsTabStop = true;
28 ToolTipService.SetToolTip(this, "拖动调整大小 · 方向键微调 · 双击恢复");
29 Loaded += (_, _) => { UpdateDirection(); ApplyPreferredSize(); };
30 PointerEntered += (_, _) => { if (!dragging) VisualStateManager.GoToState(this, "PointerOver", true); };
31 PointerExited += (_, _) => { if (!dragging) VisualStateManager.GoToState(this, "Normal", true); };
32 IsEnabledChanged += (_, _) => VisualStateManager.GoToState(this, IsEnabled ? "Normal" : "Disabled", true);
33 DoubleTapped += async (_, e) => { e.Handled = true; Reset(); await SaveAsync(); };
34 KeyDown += async (_, e) =>
35 {
36 var delta = e.Key switch
37 {
38 VirtualKey.Left when Orientation == Orientation.Vertical => -8,
39 VirtualKey.Right when Orientation == Orientation.Vertical => 8,
40 VirtualKey.Up when Orientation == Orientation.Horizontal => -8,
41 VirtualKey.Down when Orientation == Orientation.Horizontal => 8,
42 _ => 0
43 };
44 if (delta == 0 && e.Key != VirtualKey.Home) return;
45 e.Handled = true;
46 if (e.Key == VirtualKey.Home) Reset(); else ResizeBy(delta * (Reverse ? -1 : 1));
47 await SaveAsync();
48 };
49 }
50 protected override void OnApplyTemplate()
51 {
52 if (thumb is not null) { thumb.DragDelta -= Thumb_DragDelta; thumb.DragStarted -= Thumb_DragStarted; thumb.DragCompleted -= Thumb_DragCompleted; }
53 base.OnApplyTemplate(); grip = GetTemplateChild("Grip") as Border; thumb = GetTemplateChild("PART_Thumb") as Thumb;
54 if (thumb is not null) { thumb.DragDelta += Thumb_DragDelta; thumb.DragStarted += Thumb_DragStarted; thumb.DragCompleted += Thumb_DragCompleted; }
55 UpdateDirection();
56 }
57 private void Thumb_DragDelta(object sender, DragDeltaEventArgs e) => ResizeBy((Orientation == Orientation.Vertical ? e.HorizontalChange : e.VerticalChange) * (Reverse ? -1 : 1));
58 private void Thumb_DragStarted(object sender, DragStartedEventArgs e) { dragging = true; VisualStateManager.GoToState(this, "Pressed", true); }
59 private async void Thumb_DragCompleted(object sender, DragCompletedEventArgs e) { dragging = false; VisualStateManager.GoToState(this, "Normal", true); await SaveAsync(); }
60 private void UpdateDirection()
61 {
62 ProtectedCursor = InputSystemCursor.Create(Orientation == Orientation.Vertical ? InputSystemCursorShape.SizeWestEast : InputSystemCursorShape.SizeNorthSouth);
63 if (grip is not null) { grip.Width = Orientation == Orientation.Vertical ? 2 : 28; grip.Height = Orientation == Orientation.Vertical ? 28 : 2; }
64 }
65 public void Bind(string preferenceKey, double defaultSize, double minimumSize, double maximumSize, Func<double> read, Action<double> write, Func<double> availableSize)
66 {
67 key = preferenceKey; fallback = defaultSize; minimum = minimumSize; maximum = maximumSize;
68 getSize = read; setSize = write; available = availableSize;
69 PreferredSize = AppServices.Current.State.PaneSize(key, fallback); ApplyPreferredSize();
70 }
71 private double Limit
72 {
73 get { var bound = available?.Invoke() ?? maximum; return Math.Max(minimum, Math.Min(maximum, double.IsFinite(bound) && bound > 0 ? bound : maximum)); }
74 }
75 public void ApplyPreferredSize() => setSize?.Invoke(Math.Clamp(PreferredSize, minimum, Limit));
76 private void ResizeBy(double delta)
77 {
78 if (getSize is null || setSize is null || !double.IsFinite(delta)) return;
79 PreferredSize = Math.Clamp(getSize() + delta, minimum, Limit); setSize(PreferredSize);
80 }
81 public void Reset() { PreferredSize = fallback; ApplyPreferredSize(); }
82 public void ReloadPreference() { PreferredSize = AppServices.Current.State.PaneSize(key, fallback); ApplyPreferredSize(); }
83 public async Task SaveAsync()
84 {
85 if (key.Length == 0) return;
86 try { AppServices.Current.State.SetPaneSize(key, PreferredSize); await AppServices.Current.State.SaveAsync(); }
87 catch (Exception ex) { Notice?.Invoke("布局保存失败:" + ex.Message); System.Diagnostics.Debug.WriteLine(ex); }
88 }
89 }
Modified XFEWorkflow/XFEWorkflow/Controls/WorkflowCanvas.cs +2 -0
@@ -39,6 +39,7 @@ public sealed class WorkflowCanvas : UserControl
39 39 public event Action<string>? Notice;
40 40 public float ZoomFactor => scroller.ZoomFactor;
41 41 public event Action<float>? ZoomChanged;
42 public event Action<string>? NodeAdded;
42 43 public WorkflowCanvas()
43 44 {
44 45 scroller.Content = surface; Content = scroller; IsTabStop = true;
@@ -195,6 +196,7 @@ public sealed class WorkflowCanvas : UserControl
195 196 foreach (var child in descriptor.ChildGraphs) node.Children[child] = WorkflowTemplates.EmptyGraph();
196 197 if (node.CapabilityId == "core.csharp") node.Code = WorkflowTemplates.NodeCode;
197 198 Graph.Nodes.Add(node); selected.Clear(); selected.Add(node.Id); Refresh(); Changed?.Invoke(Graph); SelectionChanged?.Invoke(node);
199 NodeAdded?.Invoke(descriptor.Id);
198 200 }
199 201 public void Delete()
200 202 {
Modified XFEWorkflow/XFEWorkflow/Services/UiSmokeTest.cs +57 -3
@@ -37,6 +37,8 @@ internal static class UiSmokeTest
37 37 await Check("workflow-mode-preservation-and-categorized-palette", () => MainPage.Current!.SmokeModesAndPaletteAsync());
38 38 await Check("rapid-mode-switch-no-roslyn-cancellation-or-stale-diagnostics", () => MainPage.Current!.SmokeRapidModeSwitchAsync(output));
39 39 await Check("winui-design-layout-themes-rename-and-docked-panels", () => MainPage.Current!.SmokeDesignAsync(output));
40 await Check("compact-palette-collapse-favorites-recent-source-filter-and-keyboard", () => MainPage.Current!.SmokePaletteLibraryAsync(output));
41 await Check("native-splitters-resize-constraints-persistence-navigation-and-browser", () => MainPage.Current!.SmokeResizablePanelsAsync(output));
40 42 await Check("canvas-centered-mouse-pan-and-light-dark-theme", () => MainPage.Current!.SmokePanAsync(output));
41 43 await Check("canvas-wheel-zooms-at-pointer-without-control", () => MainPage.Current!.SmokeWheelAsync(output));
42 44 await Check("vision-plugin-form-and-offline-ocr-through-runner", () => MainPage.Current!.SmokeVisionAsync(output));
@@ -206,7 +208,11 @@ internal static class UiSmokeTest
206 208 [StructLayout(LayoutKind.Sequential)] private struct MousePacket
207 209 { public int X, Y; public uint Data, Flags, Time; public nuint ExtraInfo; }
208 210 [StructLayout(LayoutKind.Sequential)] private struct InputPacket
209 { public uint Type; public MousePacket Mouse; }
211 { public uint Type; public InputData Data; }
212 [StructLayout(LayoutKind.Sequential)] private struct KeyboardPacket
213 { public ushort Key, Scan; public uint Flags, Time; public nuint ExtraInfo; }
214 [StructLayout(LayoutKind.Explicit)] private struct InputData
215 { [FieldOffset(0)] public MousePacket Mouse; [FieldOffset(0)] public KeyboardPacket Keyboard; }
210 216 [DllImport("user32.dll", SetLastError = true)] private static extern uint SendInput(uint count, InputPacket[] inputs, int size);
211 217 internal static void MouseButtonAt(int x, int y, bool down)
212 218 => MouseAt(x, y, down ? 0x0002u : 0x0004u);
@@ -218,10 +224,58 @@ internal static class UiSmokeTest
218 224 var bounds = XFEWorkflow.Plugins.Vision.ScreenCapture.DesktopBounds;
219 225 // Move and button state form one native input packet, preventing a
220 226 // physical mouse movement between separate test setup calls.
221 var packet = new InputPacket { Mouse = new MousePacket {
227 var packet = new InputPacket { Data = new() { Mouse = new MousePacket {
222 228 X = (int)Math.Round((x - bounds.X) * 65535d / (bounds.Width - 1)),
223 229 Y = (int)Math.Round((y - bounds.Y) * 65535d / (bounds.Height - 1)),
224 Flags = 0x8000u | 0x4000u | 0x0001u | flags, Data = data } };
230 Flags = 0x8000u | 0x4000u | 0x0001u | flags, Data = data } } };
225 231 if (SendInput(1, [packet], Marshal.SizeOf<InputPacket>()) != 1) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
226 232 }
233 internal static IEnumerable<DependencyObject> Descendants(DependencyObject element)
234 {
235 yield return element;
236 for (var i = 0; i < Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChildrenCount(element); i++)
237 foreach (var child in Descendants(Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChild(element, i))) yield return child;
238 }
239 internal static void PressKey(Windows.System.VirtualKey key, bool control = false)
240 {
241 EnsureForeground();
242 InputPacket Packet(Windows.System.VirtualKey value, bool up) => new() { Type = 1, Data = new() { Keyboard = new() { Key = (ushort)value, Flags = up ? 2u : 0 } } };
243 var packets = control ? new[] { Packet(Windows.System.VirtualKey.Control, false), Packet(key, false), Packet(key, true), Packet(Windows.System.VirtualKey.Control, true) }
244 : new[] { Packet(key, false), Packet(key, true) };
245 if (SendInput((uint)packets.Length, packets, Marshal.SizeOf<InputPacket>()) != packets.Length)
246 {
247 var releases = new[] { Packet(key, true), Packet(Windows.System.VirtualKey.Control, true) };
248 SendInput((uint)releases.Length, releases, Marshal.SizeOf<InputPacket>());
249 throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
250 }
251 }
252 private static nint EnsureForeground()
253 {
254 var handle = WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow);
255 if (GetForegroundWindow() != handle) throw new InvalidOperationException("验收窗口失去焦点,已停止输入");
256 return handle;
257 }
258 private static (int X, int Y, double Scale) ElementCenter(FrameworkElement element)
259 {
260 var handle = EnsureForeground(); var root = (FrameworkElement)App.MainWindow.Content;
261 if (element.ActualWidth <= 0 || element.ActualHeight <= 0 || element.Visibility != Visibility.Visible) throw new InvalidOperationException($"测试控件未显示:{element.Name}, {element.ActualWidth}x{element.ActualHeight}, {element.Visibility}");
262 var point = element.TransformToVisual(root).TransformPoint(new(element.ActualWidth / 2, element.ActualHeight / 2));
263 var origin = new ScreenPoint(); ClientToScreen(handle, ref origin); var scale = root.XamlRoot.RasterizationScale;
264 return (origin.X + (int)Math.Round(point.X * scale), origin.Y + (int)Math.Round(point.Y * scale), scale);
265 }
266 internal static async Task ClickElementAsync(FrameworkElement element)
267 {
268 var (x, y, _) = ElementCenter(element); MouseButtonAt(x, y, true); await Task.Delay(50); MouseButtonAt(x, y, false); await Task.Delay(130);
269 }
270 internal static async Task DragElementAsync(FrameworkElement element, double dx, double dy)
271 {
272 var (x, y, scale) = ElementCenter(element);
273 try
274 {
275 MouseButtonAt(x, y, true); await Task.Delay(70);
276 for (var i = 1; i <= 8; i++) { EnsureForeground(); MouseMoveAt(x + (int)Math.Round(dx * scale * i / 8), y + (int)Math.Round(dy * scale * i / 8)); await Task.Delay(25); }
277 }
278 finally { MouseButtonAt(x + (int)Math.Round(dx * scale), y + (int)Math.Round(dy * scale), false); }
279 await Task.Delay(160);
280 }
227 281 }
Added XFEWorkflow/XFEWorkflow/Styles/EditorControls.xaml +19 -0
@@ -0,0 +1,19 @@
1 <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
2 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:XFEWorkflow.Controls">
3 <Style TargetType="controls:PaneSplitter">
4 <Setter Property="HorizontalAlignment" Value="Stretch"/><Setter Property="VerticalAlignment" Value="Stretch"/>
5 <Setter Property="UseSystemFocusVisuals" Value="True"/>
6 <Setter Property="Template"><Setter.Value><ControlTemplate TargetType="controls:PaneSplitter">
7 <Grid Background="Transparent">
8 <VisualStateManager.VisualStateGroups><VisualStateGroup x:Name="CommonStates">
9 <VisualState x:Name="Normal"/>
10 <VisualState x:Name="PointerOver"><VisualState.Setters><Setter Target="Grip.Background" Value="{ThemeResource AccentFillColorDefaultBrush}"/></VisualState.Setters></VisualState>
11 <VisualState x:Name="Pressed"><VisualState.Setters><Setter Target="Grip.Background" Value="{ThemeResource AccentFillColorDefaultBrush}"/></VisualState.Setters></VisualState>
12 <VisualState x:Name="Disabled"><VisualState.Setters><Setter Target="Grip.Opacity" Value="0.3"/></VisualState.Setters></VisualState>
13 </VisualStateGroup></VisualStateManager.VisualStateGroups>
14 <Thumb x:Name="PART_Thumb" IsTabStop="False"><Thumb.Template><ControlTemplate TargetType="Thumb"><Grid Background="Transparent"/></ControlTemplate></Thumb.Template></Thumb>
15 <Border x:Name="Grip" Width="2" Height="28" CornerRadius="1" HorizontalAlignment="Center" VerticalAlignment="Center" IsHitTestVisible="False" Background="{ThemeResource ControlStrongFillColorDefaultBrush}"/>
16 </Grid>
17 </ControlTemplate></Setter.Value></Setter>
18 </Style>
19 </ResourceDictionary>
Modified XFEWorkflow/XFEWorkflow/ViewModels/MainPageViewModel.cs +60 -5
@@ -6,14 +6,18 @@ using XFEWorkflow.Services;
6 6
7 7 namespace XFEWorkflow.ViewModels;
8 8
9 public sealed class PaletteItem(CapabilityDescriptor descriptor, string pluginName)
9 public sealed class PaletteItem(CapabilityDescriptor descriptor, string pluginName, bool isFavorite = false) : ObservableObject
10 10 {
11 private bool favorite = isFavorite;
12 public bool IsFavorite { get => favorite; set { if (SetProperty(ref favorite, value)) OnPropertyChanged(nameof(FavoriteGlyph)); } }
13 public string FavoriteGlyph => IsFavorite ? "\uE735" : "\uE734";
11 14 public CapabilityDescriptor Descriptor { get; } = descriptor;
12 15 public string Name => Descriptor.Name;
13 16 public string Category => Descriptor.Category;
14 17 public string PluginName { get; } = pluginName;
15 18 public string SourceLabel => Descriptor.PluginId == "core" ? "内置节点" : "插件 · " + PluginName;
16 19 public string Description => string.IsNullOrWhiteSpace(Descriptor.Description) ? Name + " · " + SourceLabel : Descriptor.Description;
20 public string ToolTip => Name + " · " + SourceLabel + "\n" + Description + "\n输入:" + (Descriptor.Inputs.Count == 0 ? "无" : string.Join("、", Descriptor.Inputs.Select(x => x.Label))) + "\n单击添加,或拖入画布";
17 21 public int SortOrder => Descriptor.Id switch { "core.start" => 0, "core.end" => 1, _ => 2 };
18 22 public string Glyph => ((char)(Descriptor.Id switch
19 23 {
@@ -37,14 +41,25 @@ public sealed class PaletteGroup(string name, IEnumerable<PaletteItem> items) :
37 41 {
38 42 public string Name { get; } = name;
39 43 public string CountLabel => Count.ToString();
44 private bool expanded;
45 public bool IsExpanded { get => expanded; set { expanded = value; OnPropertyChanged(new(nameof(Chevron))); } }
46 public string Chevron => IsExpanded ? "\uE70D" : "\uE76C";
47 public string Glyph => this.FirstOrDefault()?.Glyph ?? "\uE8A9";
40 48 }
49 public enum PaletteMode { All, Favorites, Recent }
50 public sealed record PaletteSourceOption(string Id, string Name);
41 51
42 52 public partial class MainPageViewModel : ViewModelBase
43 53 {
44 54 private readonly AppServices services = AppServices.Current;
45 55 private string paletteFilter = "";
56 public PaletteMode PaletteMode { get; set; }
57 public string PalettePluginFilter { get; set; } = "";
58 public event Action? PaletteChanged;
46 59 public ObservableCollection<WorkflowDefinition> Workflows { get; } = [];
47 60 public ObservableCollection<PaletteGroup> PaletteGroups { get; } = [];
61 public ObservableCollection<object> PaletteRows { get; } = [];
62 public IReadOnlyList<PaletteSourceOption> PaletteSources { get; private set; } = [];
48 63 public ObservableCollection<string> RunLines { get; } = [];
49 64 [ObservableProperty] public partial WorkflowDefinition? SelectedWorkflow { get; set; }
50 65 [ObservableProperty] public partial string Status { get; set; } = "准备就绪";
@@ -62,14 +77,54 @@ public partial class MainPageViewModel : ViewModelBase
62 77 public void RefreshPalette(string? filter = null)
63 78 {
64 79 if (filter is not null) paletteFilter = filter;
65 var names = PluginNames;
66 var items = Catalog.Values.Select(x => new PaletteItem(x, names.GetValueOrDefault(x.PluginId, x.PluginId)))
67 .Where(x => new[] { x.Name, x.Category, x.PluginName, x.Descriptor.Id }.Any(value => value.Contains(paletteFilter, StringComparison.OrdinalIgnoreCase)));
80 var names = PluginNames; var favorites = services.State.FavoriteCapabilities; var recent = services.State.RecentCapabilities;
81 var catalog = Catalog.Values.ToArray();
82 PaletteSources = new[] { new PaletteSourceOption("", "全部来源") }.Concat(catalog.Select(x => x.PluginId).Distinct()
83 .OrderBy(x => x == "core" ? 0 : 1).ThenBy(x => names.GetValueOrDefault(x, x))
84 .Select(x => new PaletteSourceOption(x, x == "core" ? "内置节点" : names.GetValueOrDefault(x, x)))).ToArray();
85 if (!PaletteSources.Any(x => x.Id == PalettePluginFilter)) PalettePluginFilter = "";
86 var terms = paletteFilter.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
87 var items = catalog.Select(x => new PaletteItem(x, names.GetValueOrDefault(x.PluginId, x.PluginId), favorites.Contains(x.Id)))
88 .Where(x => (PalettePluginFilter.Length == 0 || x.Descriptor.PluginId == PalettePluginFilter)
89 && (PaletteMode != PaletteMode.Favorites || x.IsFavorite)
90 && (PaletteMode != PaletteMode.Recent || recent.Contains(x.Descriptor.Id))
91 && terms.All(term => new[] { x.Name, x.Category, x.PluginName, x.Descriptor.Id, x.Description }.Any(value => value.Contains(term, StringComparison.OrdinalIgnoreCase)))).ToArray();
92 var expanded = services.State.ExpandedPaletteCategories;
68 93 PaletteGroups.Clear();
69 94 foreach (var group in items.GroupBy(x => x.Category).OrderBy(g => g.Key switch { "流程" => 0, "逻辑控制" => 1, "变量与数据" => 2, "代码" => 3, "基础操作" => 4, _ => 5 }).ThenBy(g => g.Key))
70 PaletteGroups.Add(new(group.Key, group.OrderBy(x => x.SortOrder).ThenBy(x => x.Name)));
95 PaletteGroups.Add(new(group.Key, group.OrderBy(x => x.SortOrder).ThenBy(x => x.Name)) { IsExpanded = terms.Length > 0 || PalettePluginFilter.Length > 0 || expanded.Contains(group.Key) });
71 96 PaletteCountLabel = PaletteGroups.Sum(x => x.Count) + " 个节点";
97 PaletteRows.Clear();
98 if (PaletteMode != PaletteMode.All)
99 {
100 foreach (var item in items.OrderBy(x => PaletteMode == PaletteMode.Recent ? recent.IndexOf(x.Descriptor.Id) : x.SortOrder).ThenBy(x => x.Name)) PaletteRows.Add(item);
101 }
102 else foreach (var group in PaletteGroups) { PaletteRows.Add(group); if (group.IsExpanded) foreach (var item in group) PaletteRows.Add(item); }
103 PaletteChanged?.Invoke();
72 104 }
105 public void TogglePaletteCategory(PaletteGroup group)
106 {
107 group.IsExpanded = !group.IsExpanded;
108 var expanded = services.State.ExpandedPaletteCategories;
109 if (group.IsExpanded) expanded.Add(group.Name); else expanded.Remove(group.Name);
110 services.State.SetExpandedPaletteCategories(expanded);
111 // A user can collapse search results without changing the search text.
112 var index = PaletteRows.IndexOf(group);
113 if (index < 0) return;
114 PaletteRows[index] = group;
115 if (group.IsExpanded) foreach (var item in group.Reverse()) PaletteRows.Insert(index + 1, item);
116 else while (index + 1 < PaletteRows.Count && PaletteRows[index + 1] is PaletteItem) PaletteRows.RemoveAt(index + 1);
117 }
118 public void ExpandPalette(bool expand)
119 {
120 services.State.SetExpandedPaletteCategories(expand ? Catalog.Values.Select(x => x.Category).Distinct() : []);
121 foreach (var group in PaletteGroups) group.IsExpanded = expand;
122 PaletteRows.Clear();
123 foreach (var group in PaletteGroups) { PaletteRows.Add(group); if (expand) foreach (var item in group) PaletteRows.Add(item); }
124 }
125 public void ToggleFavorite(PaletteItem item)
126 { item.IsFavorite = !item.IsFavorite; services.State.SetFavorite(item.Descriptor.Id, item.IsFavorite); if (PaletteMode == PaletteMode.Favorites) RefreshPalette(); }
127 public void RecordPaletteUse(string id) { services.State.RecordCapability(id); if (PaletteMode == PaletteMode.Recent) RefreshPalette(); }
73 128 public async Task NewAsync(WorkflowDefinition workflow)
74 129 {
75 130 await SaveAsync();
Modified XFEWorkflow/XFEWorkflow/Views/AppShellPage.xaml +2 -1
@@ -10,7 +10,7 @@
10 10 xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
11 11 mc:Ignorable="d" Loaded="Page_Loaded">
12 12
13 <Grid>
13 <Grid SizeChanged="ShellLayout_SizeChanged">
14 14 <TitleBar x:Name="appTitleBar" Title="XFEWorkflow" Subtitle="本地自动化工作台" Height="{Binding ElementName=navigationView, Path=CompactPaneLength}" VerticalAlignment="Top">
15 15 <TitleBar.IconSource>
16 16 <ImageIconSource ImageSource="/Assets/icon.png"/>
@@ -81,5 +81,6 @@
81 81 <controls:BrowserHost x:Name="browserHost" Opacity="0" IsHitTestVisible="False"/>
82 82 </Grid>
83 83 </NavigationView>
84 <controls:PaneSplitter x:Name="NavigationSplitter" Width="8" HorizontalAlignment="Left" Canvas.ZIndex="1" Visibility="Collapsed" AutomationProperties.Name="调整导航栏宽度"/>
84 85 </Grid>
85 86 </Page>
Modified XFEWorkflow/XFEWorkflow/Views/AppShellPage.xaml.cs +21 -0
Added XFEWorkflow/XFEWorkflow/Views/MainPage.Panels.Smoke.cs +162 -0
Added XFEWorkflow/XFEWorkflow/Views/MainPage.Panels.cs +86 -0
Modified XFEWorkflow/XFEWorkflow/Views/MainPage.xaml +62 -34
Modified XFEWorkflow/XFEWorkflow/Views/MainPage.xaml.cs +14 -7
Modified docs/acceptance.md +6 -1
Added docs/screenshots/node-library-categories-dark.png +0 -0
二进制文件已变更,无法进行逐行预览。
Added docs/screenshots/node-library-search-light.png +0 -0
二进制文件已变更,无法进行逐行预览。
Added docs/screenshots/node-library-ui-results.json +94 -0
Added docs/screenshots/workflow-resizable-panels.png +0 -0
二进制文件已变更,无法进行逐行预览。