Profiles Select Window (#7891)
Some checks are pending
release Linux / build (Release) (push) Waiting to run
release macOS / build (Release) (push) Waiting to run
release Windows desktop (Avalonia UI) / build (Release) (push) Waiting to run
release Windows / build (Release) (push) Waiting to run

* Profiles Select Window

* Sort

* wpf

* avalonia

* Allow single select

* Fix

* Add Config Type Filter

* Remove unnecessary
This commit is contained in:
DHR60 2025-09-07 18:58:59 +08:00 committed by GitHub
parent d18d74ac1c
commit 04195c2957
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1217 additions and 33 deletions

View file

@ -3030,6 +3030,15 @@ namespace ServiceLib.Resx {
} }
} }
/// <summary>
/// 查找类似 Select Profile 的本地化字符串。
/// </summary>
public static string TbSelectProfile {
get {
return ResourceManager.GetString("TbSelectProfile", resourceCulture);
}
}
/// <summary> /// <summary>
/// 查找类似 Set system proxy 的本地化字符串。 /// 查找类似 Set system proxy 的本地化字符串。
/// </summary> /// </summary>

View file

@ -1512,4 +1512,7 @@
<data name="MsgStartParsingSubscription" xml:space="preserve"> <data name="MsgStartParsingSubscription" xml:space="preserve">
<value>Start parsing and processing subscription content</value> <value>Start parsing and processing subscription content</value>
</data> </data>
<data name="TbSelectProfile" xml:space="preserve">
<value>Select Profile</value>
</data>
</root> </root>

View file

@ -1512,4 +1512,7 @@
<data name="MsgStartParsingSubscription" xml:space="preserve"> <data name="MsgStartParsingSubscription" xml:space="preserve">
<value>Start parsing and processing subscription content</value> <value>Start parsing and processing subscription content</value>
</data> </data>
<data name="TbSelectProfile" xml:space="preserve">
<value>Select Profile</value>
</data>
</root> </root>

View file

@ -1512,4 +1512,7 @@
<data name="MsgStartParsingSubscription" xml:space="preserve"> <data name="MsgStartParsingSubscription" xml:space="preserve">
<value>Start parsing and processing subscription content</value> <value>Start parsing and processing subscription content</value>
</data> </data>
<data name="TbSelectProfile" xml:space="preserve">
<value>Select Profile</value>
</data>
</root> </root>

View file

@ -1512,4 +1512,7 @@
<data name="MsgStartParsingSubscription" xml:space="preserve"> <data name="MsgStartParsingSubscription" xml:space="preserve">
<value>Start parsing and processing subscription content</value> <value>Start parsing and processing subscription content</value>
</data> </data>
<data name="TbSelectProfile" xml:space="preserve">
<value>Select Profile</value>
</data>
</root> </root>

View file

@ -1509,4 +1509,7 @@
<data name="MsgStartParsingSubscription" xml:space="preserve"> <data name="MsgStartParsingSubscription" xml:space="preserve">
<value>开始解析和处理订阅内容</value> <value>开始解析和处理订阅内容</value>
</data> </data>
<data name="TbSelectProfile" xml:space="preserve">
<value>选择配置文件</value>
</data>
</root> </root>

View file

@ -1509,4 +1509,7 @@
<data name="MsgStartParsingSubscription" xml:space="preserve"> <data name="MsgStartParsingSubscription" xml:space="preserve">
<value>開始解析和處理訂閱內容</value> <value>開始解析和處理訂閱內容</value>
</data> </data>
<data name="TbSelectProfile" xml:space="preserve">
<value>Select Profile</value>
</data>
</root> </root>

View file

@ -0,0 +1,359 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive;
using System.Reactive.Linq;
using System.Text;
using System.Threading.Tasks;
using DynamicData;
using DynamicData.Binding;
using ReactiveUI;
using ReactiveUI.Fody.Helpers;
using Splat;
namespace ServiceLib.ViewModels;
public class ProfilesSelectViewModel : MyReactiveObject
{
#region private prop
private List<ProfileItem> _lstProfile;
private string _serverFilter = string.Empty;
private Dictionary<string, bool> _dicHeaderSort = new();
private string _subIndexId = string.Empty;
// ConfigType filter state: default include-mode with all types selected
private List<EConfigType> _filterConfigTypes = new();
private bool _filterExclude = false;
#endregion private prop
#region ObservableCollection
public IObservableCollection<ProfileItemModel> ProfileItems { get; } = new ObservableCollectionExtended<ProfileItemModel>();
public IObservableCollection<SubItem> SubItems { get; } = new ObservableCollectionExtended<SubItem>();
[Reactive]
public ProfileItemModel SelectedProfile { get; set; }
public IList<ProfileItemModel> SelectedProfiles { get; set; }
[Reactive]
public SubItem SelectedSub { get; set; }
[Reactive]
public string ServerFilter { get; set; }
// Include/Exclude filter for ConfigType
public List<EConfigType> FilterConfigTypes
{
get => _filterConfigTypes;
set => this.RaiseAndSetIfChanged(ref _filterConfigTypes, value);
}
[Reactive]
public bool FilterExclude
{
get => _filterExclude;
set => this.RaiseAndSetIfChanged(ref _filterExclude, value);
}
#endregion ObservableCollection
#region Init
public ProfilesSelectViewModel(Func<EViewAction, object?, Task<bool>>? updateView)
{
_config = AppManager.Instance.Config;
_updateView = updateView;
_subIndexId = _config.SubIndexId ?? string.Empty;
#region WhenAnyValue && ReactiveCommand
this.WhenAnyValue(
x => x.SelectedSub,
y => y != null && !y.Remarks.IsNullOrEmpty() && _subIndexId != y.Id)
.Subscribe(async c => await SubSelectedChangedAsync(c));
this.WhenAnyValue(
x => x.ServerFilter,
y => y != null && _serverFilter != y)
.Subscribe(async c => await ServerFilterChanged(c));
// React to ConfigType filter changes
this.WhenAnyValue(x => x.FilterExclude)
.Skip(1)
.Subscribe(async _ => await RefreshServersBiz());
this.WhenAnyValue(x => x.FilterConfigTypes)
.Skip(1)
.Subscribe(async _ => await RefreshServersBiz());
#endregion WhenAnyValue && ReactiveCommand
_ = Init();
}
private async Task Init()
{
SelectedProfile = new();
SelectedSub = new();
// Default: include mode with all ConfigTypes selected
try
{
FilterExclude = false;
FilterConfigTypes = Enum.GetValues(typeof(EConfigType)).Cast<EConfigType>().ToList();
}
catch
{
FilterConfigTypes = new();
}
await RefreshSubscriptions();
await RefreshServers();
}
#endregion Init
#region Actions
public bool CanOk()
{
return SelectedProfile != null && !SelectedProfile.IndexId.IsNullOrEmpty();
}
public bool SelectFinish()
{
if (!CanOk())
{
return false;
}
_updateView?.Invoke(EViewAction.CloseWindow, null);
return true;
}
#endregion Actions
#region Servers && Groups
private async Task SubSelectedChangedAsync(bool c)
{
if (!c)
{
return;
}
_subIndexId = SelectedSub?.Id;
await RefreshServers();
await _updateView?.Invoke(EViewAction.ProfilesFocus, null);
}
private async Task ServerFilterChanged(bool c)
{
if (!c)
{
return;
}
_serverFilter = ServerFilter;
if (_serverFilter.IsNullOrEmpty())
{
await RefreshServers();
}
}
public async Task RefreshServers()
{
await RefreshServersBiz();
}
private async Task RefreshServersBiz()
{
var lstModel = await GetProfileItemsEx(_subIndexId, _serverFilter);
_lstProfile = JsonUtils.Deserialize<List<ProfileItem>>(JsonUtils.Serialize(lstModel)) ?? [];
ProfileItems.Clear();
ProfileItems.AddRange(lstModel);
if (lstModel.Count > 0)
{
var selected = lstModel.FirstOrDefault(t => t.IndexId == _config.IndexId);
if (selected != null)
{
SelectedProfile = selected;
}
else
{
SelectedProfile = lstModel.First();
}
}
await _updateView?.Invoke(EViewAction.DispatcherRefreshServersBiz, null);
}
public async Task RefreshSubscriptions()
{
SubItems.Clear();
SubItems.Add(new SubItem { Remarks = ResUI.AllGroupServers });
foreach (var item in await AppManager.Instance.SubItems())
{
SubItems.Add(item);
}
if (_subIndexId != null && SubItems.FirstOrDefault(t => t.Id == _subIndexId) != null)
{
SelectedSub = SubItems.FirstOrDefault(t => t.Id == _subIndexId);
}
else
{
SelectedSub = SubItems.First();
}
}
private async Task<List<ProfileItemModel>?> GetProfileItemsEx(string subid, string filter)
{
var lstModel = await AppManager.Instance.ProfileItems(_subIndexId, filter);
//await ConfigHandler.SetDefaultServer(_config, lstModel);
lstModel = (from t in lstModel
select new ProfileItemModel
{
IndexId = t.IndexId,
ConfigType = t.ConfigType,
Remarks = t.Remarks,
Address = t.Address,
Port = t.Port,
Security = t.Security,
Network = t.Network,
StreamSecurity = t.StreamSecurity,
Subid = t.Subid,
SubRemarks = t.SubRemarks,
IsActive = t.IndexId == _config.IndexId,
}).OrderBy(t => t.Sort).ToList();
// Apply ConfigType filter (include or exclude)
if (FilterConfigTypes != null && FilterConfigTypes.Count > 0)
{
if (FilterExclude)
{
lstModel = lstModel.Where(t => !FilterConfigTypes.Contains(t.ConfigType)).ToList();
}
else
{
lstModel = lstModel.Where(t => FilterConfigTypes.Contains(t.ConfigType)).ToList();
}
}
return lstModel;
}
public async Task<ProfileItem?> GetProfileItem()
{
if (string.IsNullOrEmpty(SelectedProfile?.IndexId))
{
return null;
}
var indexId = SelectedProfile.IndexId;
var item = await AppManager.Instance.GetProfileItem(indexId);
if (item is null)
{
NoticeManager.Instance.Enqueue(ResUI.PleaseSelectServer);
return null;
}
return item;
}
public async Task<List<ProfileItem>?> GetProfileItems()
{
if (SelectedProfiles == null || SelectedProfiles.Count == 0)
{
return null;
}
var lst = new List<ProfileItem>();
foreach (var sp in SelectedProfiles)
{
if (string.IsNullOrEmpty(sp?.IndexId))
{
continue;
}
var item = await AppManager.Instance.GetProfileItem(sp.IndexId);
if (item != null)
{
lst.Add(item);
}
}
if (lst.Count == 0)
{
NoticeManager.Instance.Enqueue(ResUI.PleaseSelectServer);
return null;
}
return lst;
}
public void SortServer(string colName)
{
if (colName.IsNullOrEmpty())
{
return;
}
var prop = typeof(ProfileItemModel).GetProperty(colName);
if (prop == null)
{
return;
}
_dicHeaderSort.TryAdd(colName, true);
var asc = _dicHeaderSort[colName];
var comparer = Comparer<object?>.Create((a, b) =>
{
if (ReferenceEquals(a, b))
{
return 0;
}
if (a is null)
{
return -1;
}
if (b is null)
{
return 1;
}
if (a.GetType() == b.GetType() && a is IComparable ca)
{
return ca.CompareTo(b);
}
return string.Compare(a.ToString(), b.ToString(), StringComparison.OrdinalIgnoreCase);
});
object? KeySelector(ProfileItemModel x)
{
return prop.GetValue(x);
}
IEnumerable<ProfileItemModel> sorted = asc
? ProfileItems.OrderBy(KeySelector, comparer)
: ProfileItems.OrderByDescending(KeySelector, comparer);
var list = sorted.ToList();
ProfileItems.Clear();
ProfileItems.AddRange(list);
_dicHeaderSort[colName] = !asc;
return;
}
#endregion Servers && Groups
#region Public API
// External setter for ConfigType filter
public void SetConfigTypeFilter(IEnumerable<EConfigType> types, bool exclude = false)
{
FilterConfigTypes = types?.Distinct().ToList() ?? new List<EConfigType>();
FilterExclude = exclude;
}
#endregion Public API
}

View file

@ -38,15 +38,9 @@ public class ProfilesViewModel : MyReactiveObject
[Reactive] [Reactive]
public SubItem SelectedMoveToGroup { get; set; } public SubItem SelectedMoveToGroup { get; set; }
[Reactive]
public ComboItem SelectedServer { get; set; }
[Reactive] [Reactive]
public string ServerFilter { get; set; } public string ServerFilter { get; set; }
[Reactive]
public bool BlServers { get; set; }
#endregion ObservableCollection #endregion ObservableCollection
#region Menu #region Menu
@ -115,11 +109,6 @@ public class ProfilesViewModel : MyReactiveObject
y => y != null && !y.Remarks.IsNullOrEmpty()) y => y != null && !y.Remarks.IsNullOrEmpty())
.Subscribe(async c => await MoveToGroup(c)); .Subscribe(async c => await MoveToGroup(c));
this.WhenAnyValue(
x => x.SelectedServer,
y => y != null && !y.Text.IsNullOrEmpty())
.Subscribe(async c => await ServerSelectedChanged(c));
this.WhenAnyValue( this.WhenAnyValue(
x => x.ServerFilter, x => x.ServerFilter,
y => y != null && _serverFilter != y) y => y != null && _serverFilter != y)
@ -266,7 +255,6 @@ public class ProfilesViewModel : MyReactiveObject
SelectedProfile = new(); SelectedProfile = new();
SelectedSub = new(); SelectedSub = new();
SelectedMoveToGroup = new(); SelectedMoveToGroup = new();
SelectedServer = new();
await RefreshSubscriptions(); await RefreshSubscriptions();
await RefreshServers(); await RefreshServers();
@ -613,19 +601,6 @@ public class ProfilesViewModel : MyReactiveObject
} }
} }
private async Task ServerSelectedChanged(bool c)
{
if (!c)
{
return;
}
if (SelectedServer == null || SelectedServer.ID.IsNullOrEmpty())
{
return;
}
await SetDefaultServer(SelectedServer.ID);
}
public async Task ShareServerAsync() public async Task ShareServerAsync()
{ {
var item = await AppManager.Instance.GetProfileItem(SelectedProfile.IndexId); var item = await AppManager.Instance.GetProfileItem(SelectedProfile.IndexId);

View file

@ -0,0 +1,136 @@
<Window
x:Class="v2rayN.Desktop.Views.ProfilesSelectWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:resx="clr-namespace:ServiceLib.Resx;assembly=ServiceLib"
xmlns:vms="clr-namespace:ServiceLib.ViewModels;assembly=ServiceLib"
Title="{x:Static resx:ResUI.TbSelectProfile}"
Width="800"
Height="450"
x:DataType="vms:ProfilesSelectViewModel"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<DockPanel Margin="8">
<!-- Bottom buttons -->
<StackPanel
Margin="4"
HorizontalAlignment="Center"
DockPanel.Dock="Bottom"
Orientation="Horizontal">
<Button
x:Name="btnSave"
Width="100"
Click="BtnSave_Click"
Content="{x:Static resx:ResUI.TbConfirm}" />
<Button
x:Name="btnCancel"
Width="100"
Margin="8,0"
Content="{x:Static resx:ResUI.TbCancel}" />
</StackPanel>
<Grid>
<DockPanel>
<!-- Top tools -->
<WrapPanel Margin="4" DockPanel.Dock="Top">
<ListBox
x:Name="lstGroup"
Margin="4,0"
DisplayMemberBinding="{Binding Remarks}"
ItemsSource="{Binding SubItems}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
<Button
x:Name="btnAutofitColumnWidth"
Width="32"
Height="32"
Margin="8,0"
ToolTip.Tip="{x:Static resx:ResUI.menuProfileAutofitColumnWidth}">
<Button.Content>
<PathIcon Data="{StaticResource building_fit}" />
</Button.Content>
</Button>
<TextBox
x:Name="txtServerFilter"
Width="200"
Margin="8,0"
VerticalContentAlignment="Center"
Text="{Binding ServerFilter, Mode=TwoWay}"
Watermark="{x:Static resx:ResUI.MsgServerTitle}" />
</WrapPanel>
<!-- Profiles grid -->
<DataGrid
x:Name="lstProfiles"
AutoGenerateColumns="False"
BorderThickness="1"
CanUserReorderColumns="True"
CanUserResizeColumns="True"
GridLinesVisibility="All"
HeadersVisibility="All"
IsReadOnly="True"
ItemsSource="{Binding ProfileItems}"
SelectionMode="Single">
<DataGrid.KeyBindings>
<KeyBinding Command="{Binding SelectFinish}" Gesture="Enter" />
</DataGrid.KeyBindings>
<DataGrid.Columns>
<DataGridTextColumn
Width="80"
Binding="{Binding ConfigType}"
Header="{x:Static resx:ResUI.LvServiceType}"
Tag="ConfigType" />
<DataGridTemplateColumn SortMemberPath="Remarks" Tag="Remarks">
<DataGridTemplateColumn.Header>
<TextBlock Text="{x:Static resx:ResUI.LvRemarks}" />
</DataGridTemplateColumn.Header>
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Margin="8,0" Orientation="Horizontal">
<TextBlock Text="{Binding Remarks}" />
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn
Width="120"
Binding="{Binding Address}"
Header="{x:Static resx:ResUI.LvAddress}"
Tag="Address" />
<DataGridTextColumn
Width="60"
Binding="{Binding Port}"
Header="{x:Static resx:ResUI.LvPort}"
Tag="Port" />
<DataGridTextColumn
Width="100"
Binding="{Binding Network}"
Header="{x:Static resx:ResUI.LvTransportProtocol}"
Tag="Network" />
<DataGridTextColumn
Width="100"
Binding="{Binding StreamSecurity}"
Header="{x:Static resx:ResUI.LvTLS}"
Tag="StreamSecurity" />
<DataGridTextColumn
Width="100"
Binding="{Binding SubRemarks}"
Header="{x:Static resx:ResUI.LvSubscription}"
Tag="SubRemarks" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Grid>
</DockPanel>
</Window>

View file

@ -0,0 +1,200 @@
using System.Linq;
using System.Reactive.Disposables;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.VisualTree;
using Avalonia.ReactiveUI;
using ReactiveUI;
using ServiceLib.Manager;
using v2rayN.Desktop.Common;
namespace v2rayN.Desktop.Views;
public partial class ProfilesSelectWindow : ReactiveWindow<ProfilesSelectViewModel>
{
private static Config _config;
public Task<ProfileItem?> ProfileItem => GetProfileItem();
public Task<List<ProfileItem>?> ProfileItems => GetProfileItems();
private bool _allowMultiSelect = false;
public ProfilesSelectWindow()
{
InitializeComponent();
_config = AppManager.Instance.Config;
btnAutofitColumnWidth.Click += BtnAutofitColumnWidth_Click;
txtServerFilter.KeyDown += TxtServerFilter_KeyDown;
lstProfiles.KeyDown += LstProfiles_KeyDown;
lstProfiles.SelectionChanged += LstProfiles_SelectionChanged;
lstProfiles.LoadingRow += LstProfiles_LoadingRow;
lstProfiles.Sorting += LstProfiles_Sorting;
lstProfiles.DoubleTapped += LstProfiles_DoubleTapped;
ViewModel = new ProfilesSelectViewModel(UpdateViewHandler);
DataContext = ViewModel;
this.WhenActivated(disposables =>
{
this.OneWayBind(ViewModel, vm => vm.ProfileItems, v => v.lstProfiles.ItemsSource).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedProfile, v => v.lstProfiles.SelectedItem).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSub, v => v.lstGroup.SelectedItem).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.ServerFilter, v => v.txtServerFilter.Text).DisposeWith(disposables);
});
btnCancel.Click += (s, e) => Close(false);
}
public void AllowMultiSelect(bool allow)
{
_allowMultiSelect = allow;
if (allow)
{
lstProfiles.SelectionMode = DataGridSelectionMode.Extended;
lstProfiles.SelectedItems.Clear();
}
else
{
lstProfiles.SelectionMode = DataGridSelectionMode.Single;
if (lstProfiles.SelectedItems.Count > 0)
{
var first = lstProfiles.SelectedItems[0];
lstProfiles.SelectedItems.Clear();
lstProfiles.SelectedItem = first;
}
}
}
// Expose ConfigType filter controls to callers
public void SetConfigTypeFilter(IEnumerable<EConfigType> types, bool exclude = false)
=> ViewModel?.SetConfigTypeFilter(types, exclude);
private async Task<bool> UpdateViewHandler(EViewAction action, object? obj)
{
switch (action)
{
case EViewAction.CloseWindow:
Close(true);
break;
}
return await Task.FromResult(true);
}
private void LstProfiles_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (ViewModel != null)
{
ViewModel.SelectedProfiles = lstProfiles.SelectedItems.Cast<ProfileItemModel>().ToList();
}
}
private void LstProfiles_LoadingRow(object? sender, DataGridRowEventArgs e)
{
e.Row.Header = $" {e.Row.Index + 1}";
}
private void LstProfiles_DoubleTapped(object? sender, TappedEventArgs e)
{
// 忽略表头区域的双击
if (e.Source is Control src)
{
if (src.FindAncestorOfType<DataGridColumnHeader>() != null)
{
e.Handled = true;
return;
}
// 仅当在数据行或其子元素上双击时才触发选择
if (src.FindAncestorOfType<DataGridRow>() != null)
{
ViewModel?.SelectFinish();
e.Handled = true;
}
}
}
private void LstProfiles_Sorting(object? sender, DataGridColumnEventArgs e)
{
// 自定义排序,防止默认行为导致误触发
e.Handled = true;
if (ViewModel != null && e.Column?.Tag?.ToString() != null)
{
ViewModel.SortServer(e.Column.Tag.ToString());
}
}
private void LstProfiles_KeyDown(object? sender, KeyEventArgs e)
{
if (e.KeyModifiers is KeyModifiers.Control or KeyModifiers.Meta)
{
if (e.Key == Key.A)
{
if (_allowMultiSelect)
{
lstProfiles.SelectAll();
}
e.Handled = true;
}
}
else
{
if (e.Key is Key.Enter or Key.Return)
{
ViewModel?.SelectFinish();
e.Handled = true;
}
}
}
private void BtnAutofitColumnWidth_Click(object? sender, RoutedEventArgs e)
{
AutofitColumnWidth();
}
private void AutofitColumnWidth()
{
try
{
foreach (var col in lstProfiles.Columns)
{
col.Width = new DataGridLength(1, DataGridLengthUnitType.Auto);
}
}
catch
{
}
}
private void TxtServerFilter_KeyDown(object? sender, KeyEventArgs e)
{
if (e.Key is Key.Enter or Key.Return)
{
ViewModel?.RefreshServers();
}
}
public async Task<ProfileItem?> GetProfileItem()
{
var item = await ViewModel?.GetProfileItem();
return item;
}
public async Task<List<ProfileItem>?> GetProfileItems()
{
var item = await ViewModel?.GetProfileItems();
return item;
}
private void BtnSave_Click(object sender, RoutedEventArgs e)
{
// Trigger selection finalize when Confirm is clicked
ViewModel?.SelectFinish();
}
}

View file

@ -54,13 +54,22 @@
Width="300" Width="300"
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
Text="{Binding SelectedSource.OutboundTag, Mode=TwoWay}" /> Text="{Binding SelectedSource.OutboundTag, Mode=TwoWay}" />
<TextBlock <StackPanel
Grid.Row="1" Grid.Row="1"
Grid.Column="2" Grid.Column="2"
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
Orientation="Horizontal"
HorizontalAlignment="Left" HorizontalAlignment="Left"
VerticalAlignment="Center" VerticalAlignment="Center">
Text="{x:Static resx:ResUI.TbRuleOutboundTagTip}" /> <Button
x:Name="btnSelectProfile"
Margin="0,0,8,0"
Content="{x:Static resx:ResUI.TbSelectProfile}"
Click="BtnSelectProfile_Click" />
<TextBlock
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbRuleOutboundTagTip}" />
</StackPanel>
<TextBlock <TextBlock
Grid.Row="2" Grid.Row="2"

View file

@ -3,6 +3,7 @@ using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using ReactiveUI; using ReactiveUI;
using v2rayN.Desktop.Base; using v2rayN.Desktop.Base;
using System.Threading.Tasks;
namespace v2rayN.Desktop.Views; namespace v2rayN.Desktop.Views;
@ -93,4 +94,19 @@ public partial class RoutingRuleDetailsWindow : WindowBase<RoutingRuleDetailsVie
{ {
ProcUtils.ProcessStart("https://xtls.github.io/config/routing.html#ruleobject"); ProcUtils.ProcessStart("https://xtls.github.io/config/routing.html#ruleobject");
} }
private async void BtnSelectProfile_Click(object? sender, RoutedEventArgs e)
{
var selectWindow = new ProfilesSelectWindow();
selectWindow.SetConfigTypeFilter(new[] { EConfigType.Custom }, exclude: true);
var result = await selectWindow.ShowDialog<bool?>(this);
if (result == true)
{
var profile = await selectWindow.ProfileItem;
if (profile != null)
{
cmbOutboundTag.Text = profile.Remarks;
}
}
}
} }

View file

@ -204,6 +204,12 @@
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
VerticalAlignment="Center" VerticalAlignment="Center"
Watermark="{x:Static resx:ResUI.LvPrevProfileTip}" /> Watermark="{x:Static resx:ResUI.LvPrevProfileTip}" />
<Button
Grid.Row="9"
Grid.Column="2"
Margin="{StaticResource Margin4}"
Content="{x:Static resx:ResUI.TbSelectProfile}"
Click="BtnSelectPrevProfile_Click" />
<TextBlock <TextBlock
Grid.Row="10" Grid.Row="10"
@ -218,6 +224,12 @@
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
VerticalAlignment="Center" VerticalAlignment="Center"
Watermark="{x:Static resx:ResUI.LvPrevProfileTip}" /> Watermark="{x:Static resx:ResUI.LvPrevProfileTip}" />
<Button
Grid.Row="10"
Grid.Column="2"
Margin="{StaticResource Margin4}"
Content="{x:Static resx:ResUI.TbSelectProfile}"
Click="BtnSelectNextProfile_Click" />
<TextBlock <TextBlock
Grid.Row="11" Grid.Row="11"

View file

@ -3,6 +3,7 @@ using Avalonia;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using ReactiveUI; using ReactiveUI;
using v2rayN.Desktop.Base; using v2rayN.Desktop.Base;
using System.Threading.Tasks;
namespace v2rayN.Desktop.Views; namespace v2rayN.Desktop.Views;
@ -59,4 +60,34 @@ public partial class SubEditWindow : WindowBase<SubEditViewModel>
{ {
txtRemarks.Focus(); txtRemarks.Focus();
} }
private async void BtnSelectPrevProfile_Click(object? sender, RoutedEventArgs e)
{
var selectWindow = new ProfilesSelectWindow();
selectWindow.SetConfigTypeFilter(new[] { EConfigType.Custom }, exclude: true);
var result = await selectWindow.ShowDialog<bool?>(this);
if (result == true)
{
var profile = await selectWindow.ProfileItem;
if (profile != null)
{
txtPrevProfile.Text = profile.Remarks;
}
}
}
private async void BtnSelectNextProfile_Click(object? sender, RoutedEventArgs e)
{
var selectWindow = new ProfilesSelectWindow();
selectWindow.SetConfigTypeFilter(new[] { EConfigType.Custom }, exclude: true);
var result = await selectWindow.ShowDialog<bool?>(this);
if (result == true)
{
var profile = await selectWindow.ProfileItem;
if (profile != null)
{
txtNextProfile.Text = profile.Remarks;
}
}
}
} }

View file

@ -0,0 +1,156 @@
<base:WindowBase
x:Class="v2rayN.Views.ProfilesSelectWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:base="clr-namespace:v2rayN.Base"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:reactiveui="http://reactiveui.net"
xmlns:resx="clr-namespace:ServiceLib.Resx;assembly=ServiceLib"
xmlns:vms="clr-namespace:ServiceLib.ViewModels;assembly=ServiceLib"
Title="{x:Static resx:ResUI.TbSelectProfile}"
Width="800"
Height="450"
x:TypeArguments="vms:ProfilesSelectViewModel"
Style="{StaticResource WindowGlobal}"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<DockPanel Margin="{StaticResource Margin8}">
<StackPanel
Margin="{StaticResource Margin4}"
HorizontalAlignment="Center"
DockPanel.Dock="Bottom"
Orientation="Horizontal">
<Button
x:Name="btnSave"
Width="100"
Click="BtnSave_Click"
Content="{x:Static resx:ResUI.TbConfirm}"
IsDefault="True"
Style="{StaticResource DefButton}" />
<Button
x:Name="btnCancel"
Width="100"
Margin="{StaticResource MarginLeftRight8}"
Content="{x:Static resx:ResUI.TbCancel}"
IsCancel="true"
Style="{StaticResource DefButton}" />
</StackPanel>
<Grid>
<DockPanel>
<WrapPanel Margin="{StaticResource Margin4}" DockPanel.Dock="Top">
<ListBox
x:Name="lstGroup"
MaxHeight="200"
AutomationProperties.Name="{x:Static resx:ResUI.menuSubscription}"
FontSize="{DynamicResource StdFontSize}"
ItemContainerStyle="{StaticResource MyChipListBoxItem}"
Style="{StaticResource MaterialDesignChoiceChipPrimaryOutlineListBox}">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Remarks}" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Button
x:Name="btnAutofitColumnWidth"
Width="30"
Height="30"
Margin="{StaticResource MarginLeftRight8}"
AutomationProperties.Name="{x:Static resx:ResUI.menuProfileAutofitColumnWidth}"
Style="{StaticResource MaterialDesignFloatingActionMiniLightButton}"
ToolTip="{x:Static resx:ResUI.menuProfileAutofitColumnWidth}">
<materialDesign:PackIcon VerticalAlignment="Center" Kind="ArrowSplitVertical" />
</Button>
<TextBox
x:Name="txtServerFilter"
Width="200"
Margin="{StaticResource MarginLeftRight4}"
VerticalContentAlignment="Center"
materialDesign:HintAssist.Hint="{x:Static resx:ResUI.MsgServerTitle}"
materialDesign:TextFieldAssist.HasClearButton="True"
AutomationProperties.Name="{x:Static resx:ResUI.MsgServerTitle}"
Style="{StaticResource DefTextBox}" />
</WrapPanel>
<DataGrid
x:Name="lstProfiles"
materialDesign:DataGridAssist.CellPadding="2,2"
AutoGenerateColumns="False"
BorderThickness="1"
CanUserAddRows="False"
CanUserResizeRows="False"
CanUserSortColumns="False"
EnableRowVirtualization="True"
Focusable="True"
GridLinesVisibility="All"
HeadersVisibility="All"
IsReadOnly="True"
RowHeaderWidth="40"
SelectionMode="Single"
Style="{StaticResource DefDataGrid}">
<DataGrid.InputBindings>
<KeyBinding Command="ApplicationCommands.NotACommand" Gesture="Enter" />
</DataGrid.InputBindings>
<DataGrid.Resources>
<Style BasedOn="{StaticResource MaterialDesignDataGridRow}" TargetType="DataGridRow">
<EventSetter Event="MouseDoubleClick" Handler="LstProfiles_MouseDoubleClick" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignDataGridColumnHeader}" TargetType="DataGridColumnHeader">
<EventSetter Event="Click" Handler="LstProfiles_ColumnHeader_Click" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignDataGridCell}" TargetType="DataGridCell">
<Style.Triggers>
<DataTrigger Binding="{Binding IsActive}" Value="True">
<Setter Property="Background" Value="{DynamicResource MaterialDesign.Brush.Primary.Light}" />
<Setter Property="Foreground" Value="Black" />
<Setter Property="BorderBrush" Value="{DynamicResource MaterialDesign.Brush.Primary.Light}" />
</DataTrigger>
</Style.Triggers>
</Style>
</DataGrid.Resources>
<DataGrid.Columns>
<base:MyDGTextColumn
Width="80"
Binding="{Binding ConfigType}"
ExName="ConfigType"
Header="{x:Static resx:ResUI.LvServiceType}" />
<base:MyDGTextColumn
Width="150"
Binding="{Binding Remarks}"
ExName="Remarks"
Header="{x:Static resx:ResUI.LvRemarks}" />
<base:MyDGTextColumn
Width="120"
Binding="{Binding Address}"
ExName="Address"
Header="{x:Static resx:ResUI.LvAddress}" />
<base:MyDGTextColumn
Width="60"
Binding="{Binding Port}"
ExName="Port"
Header="{x:Static resx:ResUI.LvPort}" />
<base:MyDGTextColumn
Width="100"
Binding="{Binding Network}"
ExName="Network"
Header="{x:Static resx:ResUI.LvTransportProtocol}" />
<base:MyDGTextColumn
Width="100"
Binding="{Binding StreamSecurity}"
ExName="StreamSecurity"
Header="{x:Static resx:ResUI.LvTLS}" />
<base:MyDGTextColumn
Width="100"
Binding="{Binding SubRemarks}"
ExName="SubRemarks"
Header="{x:Static resx:ResUI.LvSubscription}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Grid>
</DockPanel>
</base:WindowBase>

View file

@ -0,0 +1,194 @@
using System.Reactive.Disposables;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Threading;
using ReactiveUI;
using ServiceLib.Manager;
using Splat;
using v2rayN.Base;
namespace v2rayN.Views;
public partial class ProfilesSelectWindow
{
private static Config _config;
public Task<ProfileItem?> ProfileItem => GetProfileItem();
public Task<List<ProfileItem>?> ProfileItems => GetProfileItems();
private bool _allowMultiSelect = false;
public ProfilesSelectWindow()
{
InitializeComponent();
lstGroup.MaxHeight = Math.Floor(SystemParameters.WorkArea.Height * 0.20 / 40) * 40;
_config = AppManager.Instance.Config;
btnAutofitColumnWidth.Click += BtnAutofitColumnWidth_Click;
txtServerFilter.PreviewKeyDown += TxtServerFilter_PreviewKeyDown;
lstProfiles.PreviewKeyDown += LstProfiles_PreviewKeyDown;
lstProfiles.SelectionChanged += LstProfiles_SelectionChanged;
lstProfiles.LoadingRow += LstProfiles_LoadingRow;
ViewModel = new ProfilesSelectViewModel(UpdateViewHandler);
this.WhenActivated(disposables =>
{
this.OneWayBind(ViewModel, vm => vm.ProfileItems, v => v.lstProfiles.ItemsSource).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedProfile, v => v.lstProfiles.SelectedItem).DisposeWith(disposables);
this.OneWayBind(ViewModel, vm => vm.SubItems, v => v.lstGroup.ItemsSource).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSub, v => v.lstGroup.SelectedItem).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.ServerFilter, v => v.txtServerFilter.Text).DisposeWith(disposables);
});
}
public void AllowMultiSelect(bool allow)
{
_allowMultiSelect = allow;
if (allow)
{
lstProfiles.SelectionMode = DataGridSelectionMode.Extended;
lstProfiles.SelectedItems.Clear();
}
else
{
lstProfiles.SelectionMode = DataGridSelectionMode.Single;
if (lstProfiles.SelectedItems.Count > 0)
{
var first = lstProfiles.SelectedItems[0];
lstProfiles.SelectedItems.Clear();
lstProfiles.SelectedItem = first;
}
}
}
// Expose ConfigType filter controls to callers
public void SetConfigTypeFilter(IEnumerable<EConfigType> types, bool exclude = false)
=> ViewModel?.SetConfigTypeFilter(types, exclude);
#region Event
private async Task<bool> UpdateViewHandler(EViewAction action, object? obj)
{
switch (action)
{
case EViewAction.CloseWindow:
this.DialogResult = true;
break;
}
return await Task.FromResult(true);
}
private void LstProfiles_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{
if (ViewModel != null)
{
ViewModel.SelectedProfiles = lstProfiles.SelectedItems.Cast<ProfileItemModel>().ToList();
}
}
private void LstProfiles_LoadingRow(object? sender, DataGridRowEventArgs e)
{
e.Row.Header = $" {e.Row.GetIndex() + 1}";
}
private void LstProfiles_MouseDoubleClick(object sender, MouseButtonEventArgs e)
{
ViewModel?.SelectFinish();
}
private void LstProfiles_ColumnHeader_Click(object sender, RoutedEventArgs e)
{
var colHeader = sender as DataGridColumnHeader;
if (colHeader == null || colHeader.TabIndex < 0 || colHeader.Column == null)
{
return;
}
var colName = ((MyDGTextColumn)colHeader.Column).ExName;
ViewModel?.SortServer(colName);
}
private void menuSelectAll_Click(object sender, RoutedEventArgs e)
{
if (!_allowMultiSelect)
{
return;
}
lstProfiles.SelectAll();
}
private void LstProfiles_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl))
{
switch (e.Key)
{
case Key.A:
menuSelectAll_Click(null, null);
e.Handled = true;
break;
}
}
else
{
if (e.Key is Key.Enter or Key.Return)
{
ViewModel?.SelectFinish();
e.Handled = true;
}
}
}
private void BtnAutofitColumnWidth_Click(object sender, RoutedEventArgs e)
{
AutofitColumnWidth();
}
private void AutofitColumnWidth()
{
try
{
foreach (var it in lstProfiles.Columns)
{
it.Width = new DataGridLength(1, DataGridLengthUnitType.Auto);
}
}
catch (Exception ex)
{
Logging.SaveLog("ProfilesView", ex);
}
}
private void TxtServerFilter_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key is Key.Enter or Key.Return)
{
ViewModel?.RefreshServers();
e.Handled = true;
}
}
public async Task<ProfileItem?> GetProfileItem()
{
var item = await ViewModel?.GetProfileItem();
return item;
}
public async Task<List<ProfileItem>?> GetProfileItems()
{
var item = await ViewModel?.GetProfileItems();
return item;
}
private void BtnSave_Click(object sender, RoutedEventArgs e)
{
// Trigger selection finalize when Confirm is clicked
ViewModel?.SelectFinish();
}
#endregion Event
}

View file

@ -29,7 +29,7 @@ public partial class ProfilesView
btnAutofitColumnWidth.Click += BtnAutofitColumnWidth_Click; btnAutofitColumnWidth.Click += BtnAutofitColumnWidth_Click;
txtServerFilter.PreviewKeyDown += TxtServerFilter_PreviewKeyDown; txtServerFilter.PreviewKeyDown += TxtServerFilter_PreviewKeyDown;
lstProfiles.PreviewKeyDown += LstProfiles_PreviewKeyDown; lstProfiles.PreviewKeyDown += LstProfiles_PreviewKeyDown;
lstProfiles.SelectionChanged += lstProfiles_SelectionChanged; lstProfiles.SelectionChanged += LstProfiles_SelectionChanged;
lstProfiles.LoadingRow += LstProfiles_LoadingRow; lstProfiles.LoadingRow += LstProfiles_LoadingRow;
menuSelectAll.Click += menuSelectAll_Click; menuSelectAll.Click += menuSelectAll_Click;
@ -191,7 +191,7 @@ public partial class ProfilesView
} }
} }
private void lstProfiles_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) private void LstProfiles_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{ {
if (ViewModel != null) if (ViewModel != null)
{ {

View file

@ -72,14 +72,24 @@
IsEditable="True" IsEditable="True"
MaxDropDownHeight="1000" MaxDropDownHeight="1000"
Style="{StaticResource DefComboBox}" /> Style="{StaticResource DefComboBox}" />
<TextBlock <StackPanel
Grid.Row="1" Grid.Row="1"
Grid.Column="2" Grid.Column="2"
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" HorizontalAlignment="Left"
VerticalAlignment="Center" VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}" Orientation="Horizontal">
Text="{x:Static resx:ResUI.TbRuleOutboundTagTip}" /> <Button
Margin="{StaticResource Margin4}"
Click="BtnSelectProfile_Click"
Content="{x:Static resx:ResUI.TbSelectProfile}"
Style="{StaticResource DefButton}" />
<TextBlock
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbRuleOutboundTagTip}" />
</StackPanel>
<TextBlock <TextBlock
Grid.Row="2" Grid.Row="2"

View file

@ -1,5 +1,6 @@
using System.Reactive.Disposables; using System.Reactive.Disposables;
using System.Windows; using System.Windows;
using System.Windows.Threading;
using ReactiveUI; using ReactiveUI;
using ServiceLib.Manager; using ServiceLib.Manager;
@ -88,4 +89,18 @@ public partial class RoutingRuleDetailsWindow
{ {
ProcUtils.ProcessStart("https://xtls.github.io/config/routing.html#ruleobject"); ProcUtils.ProcessStart("https://xtls.github.io/config/routing.html#ruleobject");
} }
private async void BtnSelectProfile_Click(object sender, RoutedEventArgs e)
{
var selectWindow = new ProfilesSelectWindow();
selectWindow.SetConfigTypeFilter(new[] { EConfigType.Custom }, exclude: true);
if (selectWindow.ShowDialog() == true)
{
var profile = await selectWindow.ProfileItem;
if (profile != null)
{
cmbOutboundTag.Text = profile.Remarks;
}
}
}
} }

View file

@ -259,6 +259,14 @@
materialDesign:HintAssist.Hint="{x:Static resx:ResUI.LvPrevProfileTip}" materialDesign:HintAssist.Hint="{x:Static resx:ResUI.LvPrevProfileTip}"
AcceptsReturn="True" AcceptsReturn="True"
Style="{StaticResource MyOutlinedTextBox}" /> Style="{StaticResource MyOutlinedTextBox}" />
<Button
Grid.Row="9"
Grid.Column="2"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Click="BtnSelectPrevProfile_Click"
Content="{x:Static resx:ResUI.TbSelectProfile}"
Style="{StaticResource DefButton}" />
<TextBlock <TextBlock
Grid.Row="10" Grid.Row="10"
@ -276,6 +284,14 @@
materialDesign:HintAssist.Hint="{x:Static resx:ResUI.LvPrevProfileTip}" materialDesign:HintAssist.Hint="{x:Static resx:ResUI.LvPrevProfileTip}"
AcceptsReturn="True" AcceptsReturn="True"
Style="{StaticResource MyOutlinedTextBox}" /> Style="{StaticResource MyOutlinedTextBox}" />
<Button
Grid.Row="10"
Grid.Column="2"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Click="BtnSelectNextProfile_Click"
Content="{x:Static resx:ResUI.TbSelectProfile}"
Style="{StaticResource DefButton}" />
<TextBlock <TextBlock
Grid.Row="11" Grid.Row="11"

View file

@ -54,4 +54,32 @@ public partial class SubEditWindow
{ {
txtRemarks.Focus(); txtRemarks.Focus();
} }
private async void BtnSelectPrevProfile_Click(object sender, RoutedEventArgs e)
{
var selectWindow = new ProfilesSelectWindow();
selectWindow.SetConfigTypeFilter(new[] { EConfigType.Custom }, exclude: true);
if (selectWindow.ShowDialog() == true)
{
var profile = await selectWindow.ProfileItem;
if (profile != null)
{
txtPrevProfile.Text = profile.Remarks;
}
}
}
private async void BtnSelectNextProfile_Click(object sender, RoutedEventArgs e)
{
var selectWindow = new ProfilesSelectWindow();
selectWindow.SetConfigTypeFilter(new[] { EConfigType.Custom }, exclude: true);
if (selectWindow.ShowDialog() == true)
{
var profile = await selectWindow.ProfileItem;
if (profile != null)
{
txtNextProfile.Text = profile.Remarks;
}
}
}
} }