更新0528
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.Windows.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ConeCalorimeter.Models;
|
||||
@@ -15,31 +14,36 @@ namespace ConeCalorimeter.ViewModels;
|
||||
public sealed class TestPageViewModel : PageViewModel
|
||||
{
|
||||
private const string ResetAction = "复位";
|
||||
private const string TestAction = "测试控制";
|
||||
private const string TestStartDisplayText = "测试开始";
|
||||
private const string TestStopDisplayText = "测试结束";
|
||||
private const string FanAction = "风机";
|
||||
private const string IgniterAction = "点火器";
|
||||
private const ushort ResetCoil = 88;
|
||||
private const ushort ResetCompleteCoil = 89;
|
||||
private const ushort TestStartCoil = 65;
|
||||
private const ushort TestStopCoil = 67;
|
||||
private const ushort FanCoil = 54;
|
||||
private const ushort IgniterCoil = 53;
|
||||
private const double MinimumHeatReleaseAxisMaximum = 150;
|
||||
private const double MinimumTotalAxisMaximum = 150;
|
||||
private const double PlotAxisPaddingFactor = 1.1;
|
||||
private const int MaximumPlotPoints = 600;
|
||||
private static readonly TimeSpan ResetPulseDuration = TimeSpan.FromMilliseconds(300);
|
||||
private static readonly TimeSpan ResetCompletionTimeout = TimeSpan.FromSeconds(15);
|
||||
private const int ControlPulseReleaseRetryCount = 3;
|
||||
private static readonly TimeSpan ControlPulseDuration = TimeSpan.FromMilliseconds(300);
|
||||
private static readonly TimeSpan ControlPulseReleaseRetryDelay = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
private readonly IExperimentDataService _experimentDataService;
|
||||
private readonly ITcpDeviceConnectionService _tcpDeviceConnectionService;
|
||||
private readonly LineSeries _heatReleaseSeries;
|
||||
private readonly LineSeries _totalHeatSeries;
|
||||
private readonly LineSeries _totalSmokeSeries;
|
||||
private readonly DispatcherTimer _resetStatusTimer;
|
||||
private readonly Dictionary<string, DeviceActionViewModel> _deviceActionsByLabel = [];
|
||||
private readonly HashSet<string> _pulseActionsInProgress = [];
|
||||
private DeviceActionViewModel _testAction = null!;
|
||||
private DeviceActionViewModel _resetAction = null!;
|
||||
private bool _flameDetected;
|
||||
private bool _resetInProgress;
|
||||
private int? _lastPlottedSeconds;
|
||||
private DateTime _resetStartedAt;
|
||||
private string _lastAction = "待机";
|
||||
|
||||
public TestPageViewModel(
|
||||
@@ -83,6 +87,8 @@ public sealed class TestPageViewModel : PageViewModel
|
||||
];
|
||||
|
||||
ExecuteDeviceActionCommand = new RelayCommand<string>(ExecuteDeviceAction);
|
||||
_testAction = new DeviceActionViewModel(TestAction, ExecuteDeviceActionCommand);
|
||||
_testAction.SetDisplayText(TestStartDisplayText);
|
||||
_resetAction = new DeviceActionViewModel(ResetAction, ExecuteDeviceActionCommand);
|
||||
DeviceActions =
|
||||
[
|
||||
@@ -90,8 +96,7 @@ public sealed class TestPageViewModel : PageViewModel
|
||||
new DeviceActionViewModel("辐射锥降", ExecuteDeviceActionCommand),
|
||||
new DeviceActionViewModel("称重台升", ExecuteDeviceActionCommand),
|
||||
new DeviceActionViewModel("称重台降", ExecuteDeviceActionCommand),
|
||||
new DeviceActionViewModel("测试开始", ExecuteDeviceActionCommand),
|
||||
new DeviceActionViewModel("测试结束", ExecuteDeviceActionCommand),
|
||||
_testAction,
|
||||
new DeviceActionViewModel(FanAction, ExecuteDeviceActionCommand),
|
||||
new DeviceActionViewModel(IgniterAction, ExecuteDeviceActionCommand),
|
||||
_resetAction
|
||||
@@ -101,21 +106,10 @@ public sealed class TestPageViewModel : PageViewModel
|
||||
_deviceActionsByLabel[action.Label] = action;
|
||||
}
|
||||
|
||||
RefreshToggleActionStates();
|
||||
HeatReleasePlot = CreatePlotModel(out _heatReleaseSeries, out _totalHeatSeries, out _totalSmokeSeries);
|
||||
UpdateSnapshot(_experimentDataService.CurrentSnapshot);
|
||||
_experimentDataService.SnapshotUpdated += (_, snapshot) => UpdateSnapshot(snapshot);
|
||||
|
||||
_resetStatusTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
_resetStatusTimer.Tick += (_, _) =>
|
||||
{
|
||||
RefreshResetStatus();
|
||||
RefreshToggleActionStates();
|
||||
};
|
||||
RefreshToggleActionStates();
|
||||
_resetStatusTimer.Start();
|
||||
}
|
||||
|
||||
public ObservableCollection<MetricDisplayViewModel> TopMetrics { get; }
|
||||
@@ -329,34 +323,6 @@ public sealed class TestPageViewModel : PageViewModel
|
||||
return Math.Max(minimumMaximum, Math.Ceiling(maximum * PlotAxisPaddingFactor));
|
||||
}
|
||||
|
||||
public bool StartMomentaryDeviceAction(string? action)
|
||||
{
|
||||
return TryWriteMomentaryDeviceAction(action, true);
|
||||
}
|
||||
|
||||
public bool StopMomentaryDeviceAction(string? action)
|
||||
{
|
||||
return TryWriteMomentaryDeviceAction(action, false);
|
||||
}
|
||||
|
||||
private bool TryWriteMomentaryDeviceAction(string? action, bool isRunning)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(action)
|
||||
|| !TryGetMomentaryDeviceActionCoil(action, out var coilAddress))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
LastAction = isRunning ? action : $"停止:{action}";
|
||||
|
||||
if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, isRunning))
|
||||
{
|
||||
Debug.WriteLine($"Momentary device action '{action}' write failed.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ExecuteDeviceAction(string? action)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(action))
|
||||
@@ -364,8 +330,9 @@ public sealed class TestPageViewModel : PageViewModel
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsMomentaryDeviceAction(action))
|
||||
if (action == TestAction)
|
||||
{
|
||||
ExecuteTestControlAction();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -376,33 +343,19 @@ public sealed class TestPageViewModel : PageViewModel
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsToggleDeviceAction(action))
|
||||
if (TryGetToggleDeviceActionCoil(action, out var toggleCoilAddress))
|
||||
{
|
||||
ToggleDeviceAction(action);
|
||||
ToggleDeviceAction(action, toggleCoilAddress);
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryGetPulseDeviceActionCoil(action, out var coilAddress))
|
||||
{
|
||||
ExecutePulseDeviceAction(action, coilAddress);
|
||||
return;
|
||||
}
|
||||
|
||||
LastAction = action;
|
||||
|
||||
if (action == "测试开始")
|
||||
{
|
||||
_experimentDataService.StartTest();
|
||||
ClearPlotSeries();
|
||||
}
|
||||
else if (action == "测试结束")
|
||||
{
|
||||
_experimentDataService.StopTest();
|
||||
}
|
||||
|
||||
if (!TryGetDeviceActionCoil(action, out var coilAddress, out var value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, value))
|
||||
{
|
||||
Debug.WriteLine($"Device action '{action}' write failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private async void ExecuteResetAction()
|
||||
@@ -421,37 +374,18 @@ public sealed class TestPageViewModel : PageViewModel
|
||||
|
||||
SetResetInProgress(true);
|
||||
LastAction = "复位中";
|
||||
_resetStartedAt = DateTime.Now;
|
||||
|
||||
await Task.Delay(ResetPulseDuration);
|
||||
if (!_tcpDeviceConnectionService.TryWriteCoil(ResetCoil, false))
|
||||
await Task.Delay(ControlPulseDuration);
|
||||
if (await ReleaseControlPulseAsync(ResetCoil, ResetAction))
|
||||
{
|
||||
Debug.WriteLine("Device reset pulse release failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshResetStatus()
|
||||
{
|
||||
if (!_resetInProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var statusRead = _tcpDeviceConnectionService.TryReadCoil(ResetCompleteCoil, out var resetComplete);
|
||||
if (statusRead && resetComplete)
|
||||
{
|
||||
SetResetInProgress(false);
|
||||
LastAction = "复位完成";
|
||||
return;
|
||||
}
|
||||
|
||||
if (DateTime.Now - _resetStartedAt < ResetCompletionTimeout)
|
||||
else
|
||||
{
|
||||
return;
|
||||
LastAction = "复位线圈复位失败";
|
||||
}
|
||||
|
||||
SetResetInProgress(false);
|
||||
LastAction = statusRead ? "复位超时" : "复位状态读取失败";
|
||||
}
|
||||
|
||||
private void SetResetInProgress(bool resetInProgress)
|
||||
@@ -474,6 +408,58 @@ public sealed class TestPageViewModel : PageViewModel
|
||||
HeatReleasePlot.InvalidatePlot(true);
|
||||
}
|
||||
|
||||
private async void ExecuteTestControlAction()
|
||||
{
|
||||
if (!TryBeginPulseAction(TestAction))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var isStarting = !_testAction.IsActive;
|
||||
var actionText = isStarting ? TestStartDisplayText : TestStopDisplayText;
|
||||
var coilAddress = isStarting ? TestStartCoil : TestStopCoil;
|
||||
|
||||
if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, true))
|
||||
{
|
||||
LastAction = $"{actionText}失败";
|
||||
Debug.WriteLine($"Device action '{actionText}' write failed.");
|
||||
EndPulseAction(TestAction);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (isStarting)
|
||||
{
|
||||
_experimentDataService.StartTest();
|
||||
ClearPlotSeries();
|
||||
}
|
||||
else
|
||||
{
|
||||
_experimentDataService.StopTest();
|
||||
}
|
||||
|
||||
SetTestRunning(isStarting);
|
||||
LastAction = actionText;
|
||||
|
||||
await Task.Delay(ControlPulseDuration);
|
||||
if (!await ReleaseControlPulseAsync(coilAddress, actionText))
|
||||
{
|
||||
LastAction = $"{actionText}线圈复位失败";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndPulseAction(TestAction);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetTestRunning(bool isRunning)
|
||||
{
|
||||
_testAction.UpdateStatus(isRunning);
|
||||
_testAction.SetDisplayText(isRunning ? TestStopDisplayText : TestStartDisplayText);
|
||||
}
|
||||
|
||||
private void RefreshToggleActionStates()
|
||||
{
|
||||
UpdateToggleActionStatus(FanAction, FanCoil);
|
||||
@@ -497,17 +483,13 @@ public sealed class TestPageViewModel : PageViewModel
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ToggleDeviceAction(string action)
|
||||
private void ToggleDeviceAction(string action, ushort coilAddress)
|
||||
{
|
||||
if (!TryGetToggleDeviceActionCoil(action, out var coilAddress))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_tcpDeviceConnectionService.TryReadCoil(coilAddress, out var isActive))
|
||||
{
|
||||
LastAction = $"{action}状态读取失败";
|
||||
Debug.WriteLine($"Device action '{action}' state read failed.");
|
||||
UpdateToggleActionStatus(action, coilAddress);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -528,27 +510,71 @@ public sealed class TestPageViewModel : PageViewModel
|
||||
Debug.WriteLine($"Device action '{action}' write failed.");
|
||||
}
|
||||
|
||||
private static bool TryGetDeviceActionCoil(string action, out ushort coilAddress, out bool value)
|
||||
private async void ExecutePulseDeviceAction(string action, ushort coilAddress)
|
||||
{
|
||||
value = true;
|
||||
|
||||
switch (action)
|
||||
if (!TryBeginPulseAction(action))
|
||||
{
|
||||
case "测试开始":
|
||||
coilAddress = 65;
|
||||
return true;
|
||||
case "测试结束":
|
||||
coilAddress = 67;
|
||||
return true;
|
||||
default:
|
||||
coilAddress = 0;
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
_deviceActionsByLabel.TryGetValue(action, out var actionViewModel);
|
||||
|
||||
if (!_tcpDeviceConnectionService.TryWriteCoil(coilAddress, true))
|
||||
{
|
||||
LastAction = $"{action}失败";
|
||||
Debug.WriteLine($"Device action '{action}' write failed.");
|
||||
EndPulseAction(action);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
actionViewModel?.UpdateStatus(true);
|
||||
LastAction = action;
|
||||
|
||||
await Task.Delay(ControlPulseDuration);
|
||||
if (await ReleaseControlPulseAsync(coilAddress, action))
|
||||
{
|
||||
actionViewModel?.UpdateStatus(false);
|
||||
return;
|
||||
}
|
||||
|
||||
actionViewModel?.UpdateStatus(true, false);
|
||||
LastAction = $"{action}线圈复位失败";
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndPulseAction(action);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsToggleDeviceAction(string action)
|
||||
private bool TryBeginPulseAction(string action)
|
||||
{
|
||||
return TryGetToggleDeviceActionCoil(action, out _);
|
||||
return _pulseActionsInProgress.Add(action);
|
||||
}
|
||||
|
||||
private void EndPulseAction(string action)
|
||||
{
|
||||
_pulseActionsInProgress.Remove(action);
|
||||
}
|
||||
|
||||
private async Task<bool> ReleaseControlPulseAsync(ushort coilAddress, string actionText)
|
||||
{
|
||||
for (var attempt = 1; attempt <= ControlPulseReleaseRetryCount; attempt++)
|
||||
{
|
||||
if (_tcpDeviceConnectionService.TryWriteCoil(coilAddress, false))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attempt < ControlPulseReleaseRetryCount)
|
||||
{
|
||||
await Task.Delay(ControlPulseReleaseRetryDelay);
|
||||
}
|
||||
}
|
||||
|
||||
Debug.WriteLine($"Device action '{actionText}' pulse release failed.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetToggleDeviceActionCoil(string action, out ushort coilAddress)
|
||||
@@ -567,12 +593,7 @@ public sealed class TestPageViewModel : PageViewModel
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsMomentaryDeviceAction(string action)
|
||||
{
|
||||
return TryGetMomentaryDeviceActionCoil(action, out _);
|
||||
}
|
||||
|
||||
private static bool TryGetMomentaryDeviceActionCoil(string action, out ushort coilAddress)
|
||||
private static bool TryGetPulseDeviceActionCoil(string action, out ushort coilAddress)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#1B2826" />
|
||||
<TextBlock Margin="0,22,0,0"
|
||||
Text="1. 0%为不透光校准;"
|
||||
Text="1. 0%为不遮光校准;"
|
||||
FontSize="22"
|
||||
Foreground="#111A18" />
|
||||
<TextBlock Margin="0,14,0,0"
|
||||
|
||||
@@ -262,29 +262,20 @@
|
||||
<ItemsControl ItemsSource="{Binding DeviceActions}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Columns="3" Rows="3" />
|
||||
<UniformGrid Columns="2" Rows="4" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Button Content="{Binding DisplayText}"
|
||||
Command="{Binding Command}"
|
||||
CommandParameter="{Binding Label}"
|
||||
Margin="5,3"
|
||||
MinHeight="40"
|
||||
PreviewMouseLeftButtonDown="DeviceActionButton_PreviewMouseLeftButtonDown"
|
||||
PreviewMouseLeftButtonUp="DeviceActionButton_PreviewMouseLeftButtonUp"
|
||||
MouseLeave="DeviceActionButton_MouseLeave"
|
||||
LostMouseCapture="DeviceActionButton_LostMouseCapture"
|
||||
LostKeyboardFocus="DeviceActionButton_LostKeyboardFocus"
|
||||
TouchDown="DeviceActionButton_TouchDown"
|
||||
TouchUp="DeviceActionButton_TouchUp"
|
||||
TouchLeave="DeviceActionButton_TouchLeave"
|
||||
LostTouchCapture="DeviceActionButton_LostTouchCapture"
|
||||
Style="{StaticResource StatusDeviceActionButtonStyle}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -1,159 +1,11 @@
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using ConeCalorimeter.ViewModels;
|
||||
|
||||
namespace ConeCalorimeter.Views;
|
||||
|
||||
public partial class TestPageView : UserControl
|
||||
{
|
||||
private readonly HashSet<string> _runningMomentaryActions = [];
|
||||
|
||||
public TestPageView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void DeviceActionButton_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (StartMomentaryAction(sender))
|
||||
{
|
||||
if (sender is Button button)
|
||||
{
|
||||
button.CaptureMouse();
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void DeviceActionButton_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (StopMomentaryAction(sender))
|
||||
{
|
||||
ReleaseInputCapture(sender);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void DeviceActionButton_MouseLeave(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (StopMomentaryAction(sender))
|
||||
{
|
||||
ReleaseInputCapture(sender);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeviceActionButton_LostMouseCapture(object sender, MouseEventArgs e)
|
||||
{
|
||||
StopMomentaryAction(sender);
|
||||
}
|
||||
|
||||
private void DeviceActionButton_LostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
|
||||
{
|
||||
if (StopMomentaryAction(sender))
|
||||
{
|
||||
ReleaseInputCapture(sender);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeviceActionButton_TouchDown(object sender, TouchEventArgs e)
|
||||
{
|
||||
if (StartMomentaryAction(sender))
|
||||
{
|
||||
if (sender is Button button)
|
||||
{
|
||||
button.CaptureTouch(e.TouchDevice);
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void DeviceActionButton_TouchUp(object sender, TouchEventArgs e)
|
||||
{
|
||||
if (StopMomentaryAction(sender))
|
||||
{
|
||||
ReleaseInputCapture(sender);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void DeviceActionButton_TouchLeave(object sender, TouchEventArgs e)
|
||||
{
|
||||
if (StopMomentaryAction(sender))
|
||||
{
|
||||
ReleaseInputCapture(sender);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeviceActionButton_LostTouchCapture(object sender, TouchEventArgs e)
|
||||
{
|
||||
StopMomentaryAction(sender);
|
||||
}
|
||||
|
||||
private bool StartMomentaryAction(object sender)
|
||||
{
|
||||
if (!TryGetDeviceAction(sender, out var label, out var viewModel))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_runningMomentaryActions.Contains(label))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!viewModel.StartMomentaryDeviceAction(label))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_runningMomentaryActions.Add(label);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool StopMomentaryAction(object sender)
|
||||
{
|
||||
if (!TryGetDeviceAction(sender, out var label, out var viewModel)
|
||||
|| !_runningMomentaryActions.Remove(label))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return viewModel.StopMomentaryDeviceAction(label);
|
||||
}
|
||||
|
||||
private bool TryGetDeviceAction(
|
||||
object sender,
|
||||
out string label,
|
||||
out TestPageViewModel viewModel)
|
||||
{
|
||||
label = string.Empty;
|
||||
viewModel = null!;
|
||||
|
||||
if (sender is not Button { DataContext: DeviceActionViewModel action }
|
||||
|| DataContext is not TestPageViewModel testPageViewModel)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
label = action.Label;
|
||||
viewModel = testPageViewModel;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ReleaseInputCapture(object sender)
|
||||
{
|
||||
if (sender is not Button button)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.IsMouseCaptured)
|
||||
{
|
||||
button.ReleaseMouseCapture();
|
||||
}
|
||||
|
||||
button.ReleaseAllTouchCaptures();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user