返回提交历史
Modified
README.md
+4
-1
Modified
XFEWorkflow/XFEWorkflow.Infrastructure/Storage/WorkspaceStorage.cs
+22
-0
Added
XFEWorkflow/XFEWorkflow.Test/EditorPreferencesTests.cs
+42
-0
Modified
XFEWorkflow/XFEWorkflow/App.xaml
+1
-0
Modified
XFEWorkflow/XFEWorkflow/Controls/BrowserHost.cs
+15
-6
Added
XFEWorkflow/XFEWorkflow/Controls/PaletteTemplateSelector.cs
+11
-0
Added
XFEWorkflow/XFEWorkflow/Controls/PaneSplitter.cs
+89
-0
Modified
XFEWorkflow/XFEWorkflow/Controls/WorkflowCanvas.cs
+2
-0
Modified
XFEWorkflow/XFEWorkflow/Services/UiSmokeTest.cs
+57
-3
Added
XFEWorkflow/XFEWorkflow/Styles/EditorControls.xaml
+19
-0
Modified
XFEWorkflow/XFEWorkflow/ViewModels/MainPageViewModel.cs
+60
-5
Modified
XFEWorkflow/XFEWorkflow/Views/AppShellPage.xaml
+2
-1
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
XFEstudio/XFEWorkflow
重设计节点库并支持各面板拖动调整尺寸
ff19ade
代码差异
22 个文件
+769
-58
@@ -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
| 项目 | 职责 |
@@ -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
}
@@ -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
}
@@ -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"/>
@@ -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
{
@@ -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
}
@@ -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
}
@@ -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
{
@@ -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
}
@@ -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>
@@ -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();
@@ -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>
二进制文件已变更,无法进行逐行预览。
二进制文件已变更,无法进行逐行预览。
二进制文件已变更,无法进行逐行预览。